@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.
- package/bin/belte.ts +22 -13
- package/package.json +1 -1
- package/src/appEntry.ts +24 -8
- package/src/buildDisconnected.ts +3 -0
- package/src/bundleApp.ts +24 -2
- package/src/controlServerWorker.ts +205 -7
- 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/BundleWindow.ts +11 -0
- package/src/lib/bundle/disconnected.svelte +238 -42
- 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/loadEnvFromBinaryDir.ts +8 -38
- 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 +57 -21
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/runtime/parsePort.ts +16 -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/appDataDir.ts +22 -0
- 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/loadEnvFile.ts +17 -0
- package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
- package/src/lib/shared/parseEnv.ts +30 -0
- package/src/lib/shared/readEnvFile.ts +15 -0
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/serializeEnv.ts +18 -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/src/serverEntry.ts +12 -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,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
|
-
|
|
8
|
-
`process.execPath`)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/cli/runCli.ts
CHANGED
|
@@ -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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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-
|
|
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()
|