brustjs 0.1.11-alpha → 0.1.13-alpha
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/README.md +13 -8
- package/package.json +9 -8
- package/runtime/actions.ts +7 -65
- package/runtime/cli/build.ts +27 -27
- package/runtime/cli/dev.ts +2 -2
- package/runtime/cli/native-routes-emit.ts +3 -3
- package/runtime/cli/templates/minimal/tsconfig.json +1 -1
- package/runtime/client/index.ts +5 -104
- package/runtime/define-actions.ts +179 -0
- package/runtime/index.d.ts +13 -7
- package/runtime/index.js +52 -52
- package/runtime/index.ts +65 -52
- package/runtime/islands/brust-page.tsx +3 -2
- package/runtime/islands/island.tsx +7 -1
- package/runtime/mcp/extractor.ts +240 -88
- package/runtime/mcp/manifest.ts +2 -1
- package/runtime/mcp/server.ts +28 -37
- package/runtime/render/inject-action-prefix.ts +60 -0
- package/runtime/render/inject-css-link.ts +1 -1
- package/runtime/render/stream.ts +5 -2
- package/runtime/routes.ts +189 -65
- package/runtime/standard-schema.ts +29 -0
- package/runtime/treaty.ts +156 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +0 -97
- package/runtime/scan-actions.ts +0 -172
package/runtime/mcp/extractor.ts
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
1
4
|
import ts from 'typescript'
|
|
2
|
-
import { tsTypeToJsonSchema, type JsonSchema } from './schema.ts'
|
|
3
|
-
import type { McpManifest, ToolSchema, ResourceSchema } from './manifest.ts'
|
|
4
|
-
import type { ActionDef } from '../actions.ts'
|
|
5
5
|
import type { FlatRoute } from '../routes.ts'
|
|
6
|
+
import type { McpManifest, ResourceSchema, ToolSchema } from './manifest.ts'
|
|
7
|
+
import { type JsonSchema, tsTypeToJsonSchema } from './schema.ts'
|
|
6
8
|
|
|
7
9
|
export interface ExtractOptions {
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/** Module exporting `defineActions(...)`. Convention `<scanRoot>/actions.ts`.
|
|
11
|
+
* Absent → zero tools (resources still extracted). */
|
|
12
|
+
actionsFile?: string
|
|
13
|
+
/** The routes module file. */
|
|
11
14
|
routesFile: string
|
|
12
|
-
/**
|
|
13
|
-
* resolution; currently ignored — pass at least one for forward compat. */
|
|
15
|
+
/** User source roots. Reserved for future tsconfig resolution. */
|
|
14
16
|
sourceRoots: string[]
|
|
15
|
-
/** Result of `await brust.scanActions(...)` — maps action ids to ActionDef. */
|
|
16
|
-
actions: ActionDef[]
|
|
17
17
|
/** Result of `defineRoutes(...)`. */
|
|
18
18
|
routes: FlatRoute[]
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const METHODS: Record<string, ToolSchema['method']> = {
|
|
22
|
+
get: 'GET',
|
|
23
|
+
post: 'POST',
|
|
24
|
+
put: 'PUT',
|
|
25
|
+
patch: 'PATCH',
|
|
26
|
+
delete: 'DELETE',
|
|
27
|
+
head: 'HEAD',
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManifest> {
|
|
31
|
+
const rootNames = [opts.routesFile]
|
|
32
|
+
if (opts.actionsFile) rootNames.unshift(opts.actionsFile)
|
|
33
|
+
// Resolve bare specifiers (zod, etc.) that the actions file imports. Normal
|
|
34
|
+
// walk-up resolution finds the user's own node_modules; the wildcard fallback
|
|
35
|
+
// points at the brust runtime's node_modules so inferred schema types resolve
|
|
36
|
+
// even when the actions file lives outside a node_modules tree (e.g. tests).
|
|
37
|
+
const runtimeNodeModules = findRuntimeNodeModules()
|
|
38
|
+
const paths: Record<string, string[]> = { '*': ['*'] }
|
|
39
|
+
if (runtimeNodeModules) paths['*'].push(join(runtimeNodeModules, '*'))
|
|
22
40
|
const program = ts.createProgram({
|
|
23
|
-
rootNames
|
|
41
|
+
rootNames,
|
|
24
42
|
options: {
|
|
25
43
|
target: ts.ScriptTarget.ES2022,
|
|
26
44
|
module: ts.ModuleKind.ESNext,
|
|
@@ -29,6 +47,10 @@ export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManif
|
|
|
29
47
|
allowJs: false,
|
|
30
48
|
noEmit: true,
|
|
31
49
|
skipLibCheck: true,
|
|
50
|
+
// The brust runtime imports modules with explicit `.ts` extensions.
|
|
51
|
+
allowImportingTsExtensions: true,
|
|
52
|
+
baseUrl: opts.sourceRoots[0] ?? dirname(rootNames[0]),
|
|
53
|
+
paths,
|
|
32
54
|
// Required: TS 5.x collapses string | null to plain string without strict
|
|
33
55
|
// null checks, which would silently drop the null variant from the schema.
|
|
34
56
|
strictNullChecks: true,
|
|
@@ -37,95 +59,235 @@ export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManif
|
|
|
37
59
|
const checker = program.getTypeChecker()
|
|
38
60
|
|
|
39
61
|
const tools: ToolSchema[] = []
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const sf = program.getSourceFile(serverFile)
|
|
44
|
-
if (!sf) continue
|
|
45
|
-
ts.forEachChild(sf, (node) => {
|
|
46
|
-
const tool = extractToolFromNode(checker, node, actionIds)
|
|
47
|
-
if (tool) tools.push(tool)
|
|
48
|
-
})
|
|
62
|
+
if (opts.actionsFile) {
|
|
63
|
+
const sf = program.getSourceFile(opts.actionsFile)
|
|
64
|
+
if (sf) extractToolsFromActions(checker, sf, tools)
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
const resources = extractResources(checker, opts.routes, program.getSourceFile(opts.routesFile))
|
|
52
68
|
|
|
53
|
-
// Sort tools/resources alphabetically for stable manifest.
|
|
54
69
|
tools.sort((a, b) => a.name.localeCompare(b.name))
|
|
55
70
|
resources.sort((a, b) => a.uriTemplate.localeCompare(b.uriTemplate))
|
|
56
|
-
|
|
57
71
|
return { version: 1, tools, resources }
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
|
|
74
|
+
// Walk up from this module's directory to the nearest node_modules that has a
|
|
75
|
+
// resolvable schema dep (zod). Returns the node_modules path or undefined.
|
|
76
|
+
function findRuntimeNodeModules(): string | undefined {
|
|
77
|
+
let cur = dirname(fileURLToPath(import.meta.url))
|
|
78
|
+
for (let i = 0; i < 12; i++) {
|
|
79
|
+
const nm = join(cur, 'node_modules')
|
|
80
|
+
if (existsSync(join(nm, 'zod'))) return nm
|
|
81
|
+
const parent = dirname(cur)
|
|
82
|
+
if (parent === cur) break
|
|
83
|
+
cur = parent
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractToolsFromActions(
|
|
61
89
|
checker: ts.TypeChecker,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
):
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
node
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
sf: ts.SourceFile,
|
|
91
|
+
out: ToolSchema[],
|
|
92
|
+
): void {
|
|
93
|
+
// Find any `defineActions()` method chain anywhere in the file (export const
|
|
94
|
+
// actions = defineActions()... — but tolerate other shapes).
|
|
95
|
+
let chainTip: ts.CallExpression | undefined
|
|
96
|
+
const visit = (node: ts.Node) => {
|
|
97
|
+
if (!chainTip && ts.isCallExpression(node) && isDefineActionsChain(node)) {
|
|
98
|
+
chainTip = node
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
ts.forEachChild(node, visit)
|
|
74
102
|
}
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
ts.forEachChild(sf, visit)
|
|
104
|
+
if (!chainTip) return
|
|
105
|
+
|
|
106
|
+
const calls = collectChainCalls(chainTip) // registration order
|
|
107
|
+
const seen = new Set<string>()
|
|
108
|
+
for (const call of calls) {
|
|
109
|
+
// safe: collectChainCalls only enqueues nodes with a PropertyAccessExpression receiver
|
|
110
|
+
const prop = call.expression as ts.PropertyAccessExpression
|
|
111
|
+
const method = METHODS[prop.name.text.toLowerCase()]
|
|
112
|
+
if (!method) continue // .use(...) etc.
|
|
113
|
+
const pathArg = call.arguments[0]
|
|
114
|
+
if (!pathArg || !ts.isStringLiteralLike(pathArg)) {
|
|
115
|
+
console.warn(`[brust mcp] skipping endpoint with non-literal path (.${prop.name.text})`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const path = pathArg.text
|
|
119
|
+
const handler = call.arguments[1]
|
|
120
|
+
const optsArg = call.arguments[2]
|
|
121
|
+
const tool = buildTool(checker, method, path, call, handler, optsArg)
|
|
122
|
+
if (seen.has(tool.name)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[brust mcp] duplicate tool name "${tool.name}" from ${method} ${path}; ` +
|
|
125
|
+
`another endpoint slugs to the same name. Rename a path.`,
|
|
126
|
+
)
|
|
92
127
|
}
|
|
128
|
+
seen.add(tool.name)
|
|
129
|
+
out.push(tool)
|
|
93
130
|
}
|
|
94
|
-
return null
|
|
95
131
|
}
|
|
96
132
|
|
|
97
|
-
|
|
133
|
+
// True when the innermost receiver of this call chain is `defineActions(...)`.
|
|
134
|
+
function isDefineActionsChain(node: ts.CallExpression): boolean {
|
|
135
|
+
let cur: ts.Expression = node
|
|
136
|
+
while (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
|
|
137
|
+
cur = cur.expression.expression
|
|
138
|
+
}
|
|
139
|
+
return (
|
|
140
|
+
ts.isCallExpression(cur) &&
|
|
141
|
+
ts.isIdentifier(cur.expression) &&
|
|
142
|
+
cur.expression.text === 'defineActions'
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Collect each `.method(...)` CallExpression, outermost→inward, then reverse to
|
|
147
|
+
// registration order.
|
|
148
|
+
function collectChainCalls(node: ts.CallExpression): ts.CallExpression[] {
|
|
149
|
+
const acc: ts.CallExpression[] = []
|
|
150
|
+
let cur: ts.Expression = node
|
|
151
|
+
while (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
|
|
152
|
+
acc.push(cur)
|
|
153
|
+
cur = cur.expression.expression
|
|
154
|
+
}
|
|
155
|
+
return acc.reverse()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildTool(
|
|
98
159
|
checker: ts.TypeChecker,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
method: ToolSchema['method'],
|
|
161
|
+
path: string,
|
|
162
|
+
call: ts.CallExpression,
|
|
163
|
+
handler: ts.Expression | undefined,
|
|
164
|
+
optsArg: ts.Expression | undefined,
|
|
103
165
|
): ToolSchema {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
paramOrder.push(pname)
|
|
113
|
-
const t = checker.getTypeAtLocation(p)
|
|
114
|
-
const schema = tsTypeToJsonSchema(t, { checker }) ?? {}
|
|
115
|
-
properties[pname] = schema
|
|
116
|
-
// Rest params (...args) are always optional at the call site — never required.
|
|
117
|
-
if (!p.questionToken && !p.dotDotDotToken) required.push(pname)
|
|
166
|
+
const props: Record<string, JsonSchema> = {}
|
|
167
|
+
|
|
168
|
+
// params — from path {x}/{*x} segments (always string, required).
|
|
169
|
+
const paramNames = parsePathParams(path)
|
|
170
|
+
if (paramNames.length > 0) {
|
|
171
|
+
const pprops: Record<string, JsonSchema> = {}
|
|
172
|
+
for (const p of paramNames) pprops[p] = { type: 'string' }
|
|
173
|
+
props.params = { type: 'object', properties: pprops, required: [...paramNames] }
|
|
118
174
|
}
|
|
119
|
-
|
|
120
|
-
|
|
175
|
+
|
|
176
|
+
// opts literal — detect body/query presence + read description.
|
|
177
|
+
const optsLit = optsArg && ts.isObjectLiteralExpression(optsArg) ? optsArg : undefined
|
|
178
|
+
const hasBody = optsLit ? hasProp(optsLit, 'body') : false
|
|
179
|
+
const hasQuery = optsLit ? hasProp(optsLit, 'query') : false
|
|
180
|
+
const description = optsLit ? readStringProp(optsLit, 'description') : undefined
|
|
181
|
+
|
|
182
|
+
// handler ctx type — for body/query inference. Derived from the builder
|
|
183
|
+
// call's resolved signature (not the user's arrow) so it works regardless of
|
|
184
|
+
// whether the handler destructures its ctx param.
|
|
185
|
+
const ctxType = callCtxType(checker, call)
|
|
186
|
+
if (hasBody && ctxType) {
|
|
187
|
+
const s = ctxMemberSchema(checker, ctxType, 'body')
|
|
188
|
+
if (s) props.body = s
|
|
189
|
+
}
|
|
190
|
+
if (hasQuery && ctxType) {
|
|
191
|
+
const s = ctxMemberSchema(checker, ctxType, 'query')
|
|
192
|
+
if (s) props.query = s
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const inputSchema: JsonSchema = { type: 'object', properties: props }
|
|
121
196
|
|
|
122
197
|
let outputSchema: JsonSchema | undefined
|
|
123
|
-
if (
|
|
124
|
-
const
|
|
125
|
-
outputSchema = tsTypeToJsonSchema(
|
|
198
|
+
if (handler) {
|
|
199
|
+
const ret = handlerReturnType(checker, handler)
|
|
200
|
+
if (ret) outputSchema = tsTypeToJsonSchema(ret, { checker, unwrapPromise: true })
|
|
126
201
|
}
|
|
127
202
|
|
|
128
|
-
return { name, description, inputSchema, outputSchema
|
|
203
|
+
return { name: toolName(method, path), description, method, path, inputSchema, outputSchema }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toolName(method: ToolSchema['method'], path: string): string {
|
|
207
|
+
const slug = path
|
|
208
|
+
.replace(/^\//, '')
|
|
209
|
+
.split('/')
|
|
210
|
+
.filter((s) => s.length > 0)
|
|
211
|
+
.map((seg) =>
|
|
212
|
+
seg.startsWith('{') && seg.endsWith('}') ? `by_${seg.slice(1, -1).replace(/^\*/, '')}` : seg,
|
|
213
|
+
)
|
|
214
|
+
.join('_')
|
|
215
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
216
|
+
// Root path '/' yields an empty slug — name it '<method>_root' so it stays
|
|
217
|
+
// descriptive and distinct rather than a bare '<method>'.
|
|
218
|
+
if (slug === '') return `${method.toLowerCase()}_root`
|
|
219
|
+
return `${method.toLowerCase()}_${slug}`.replace(/_+$/, '')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parsePathParams(path: string): string[] {
|
|
223
|
+
const out: string[] = []
|
|
224
|
+
for (const m of path.matchAll(/\{(\*?)([^}]+)\}/g)) out.push(m[2])
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hasProp(lit: ts.ObjectLiteralExpression, name: string): boolean {
|
|
229
|
+
return lit.properties.some(
|
|
230
|
+
(p) =>
|
|
231
|
+
(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
|
|
232
|
+
p.name &&
|
|
233
|
+
ts.isIdentifier(p.name) &&
|
|
234
|
+
p.name.text === name,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readStringProp(lit: ts.ObjectLiteralExpression, name: string): string | undefined {
|
|
239
|
+
for (const p of lit.properties) {
|
|
240
|
+
if (
|
|
241
|
+
ts.isPropertyAssignment(p) &&
|
|
242
|
+
ts.isIdentifier(p.name) &&
|
|
243
|
+
p.name.text === name &&
|
|
244
|
+
ts.isStringLiteralLike(p.initializer)
|
|
245
|
+
) {
|
|
246
|
+
return p.initializer.text
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The handler's ctx (first param) type, via the RESOLVED signature of the
|
|
253
|
+
// builder call (param[1] = handler). Reading the builder's parameter type —
|
|
254
|
+
// not the user's arrow — makes body/query inference independent of whether the
|
|
255
|
+
// handler actually destructures its ctx: a `() => ...` handler that declares a
|
|
256
|
+
// `body` schema still yields a rich body schema.
|
|
257
|
+
function callCtxType(checker: ts.TypeChecker, call: ts.CallExpression): ts.Type | undefined {
|
|
258
|
+
const rs = checker.getResolvedSignature(call)
|
|
259
|
+
const handlerParam = rs?.getParameters()[1]
|
|
260
|
+
if (!handlerParam) return undefined
|
|
261
|
+
const hd = handlerParam.valueDeclaration ?? handlerParam.declarations?.[0]
|
|
262
|
+
if (!hd) return undefined
|
|
263
|
+
const ht = checker.getTypeOfSymbolAtLocation(handlerParam, hd)
|
|
264
|
+
const sig = ht.getCallSignatures()[0]
|
|
265
|
+
const p0 = sig?.getParameters()[0]
|
|
266
|
+
if (!p0) return undefined
|
|
267
|
+
const pd = p0.valueDeclaration ?? p0.declarations?.[0]
|
|
268
|
+
if (!pd) return undefined
|
|
269
|
+
return checker.getTypeOfSymbolAtLocation(p0, pd)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function handlerReturnType(checker: ts.TypeChecker, handler: ts.Expression): ts.Type | undefined {
|
|
273
|
+
const t = checker.getTypeAtLocation(handler)
|
|
274
|
+
const sig = t.getCallSignatures()[0]
|
|
275
|
+
return sig ? checker.getReturnTypeOfSignature(sig) : undefined
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ctxMemberSchema(
|
|
279
|
+
checker: ts.TypeChecker,
|
|
280
|
+
ctxType: ts.Type,
|
|
281
|
+
member: 'body' | 'query',
|
|
282
|
+
): JsonSchema | undefined {
|
|
283
|
+
const sym = ctxType.getProperty(member)
|
|
284
|
+
if (!sym) return undefined
|
|
285
|
+
const decl = sym.valueDeclaration ?? sym.declarations?.[0]
|
|
286
|
+
const t = decl
|
|
287
|
+
? checker.getTypeOfSymbolAtLocation(sym, decl)
|
|
288
|
+
: (checker as unknown as { getTypeOfSymbol?: (s: ts.Symbol) => ts.Type }).getTypeOfSymbol?.(sym)
|
|
289
|
+
if (!t) return undefined
|
|
290
|
+
return tsTypeToJsonSchema(t, { checker })
|
|
129
291
|
}
|
|
130
292
|
|
|
131
293
|
function extractResources(
|
|
@@ -148,13 +310,3 @@ function extractResources(
|
|
|
148
310
|
}
|
|
149
311
|
return out
|
|
150
312
|
}
|
|
151
|
-
|
|
152
|
-
function getJsdoc(node: ts.Node): string | undefined {
|
|
153
|
-
const tags = ts.getJSDocCommentsAndTags(node)
|
|
154
|
-
if (tags.length === 0) return undefined
|
|
155
|
-
const first = tags[0]
|
|
156
|
-
// JSDoc `comment` can be either a string or a NodeArray<JSDocComment> when
|
|
157
|
-
// inline tags like {@link} are present — ts.getTextOfJSDocComment normalises.
|
|
158
|
-
if (ts.isJSDoc(first)) return ts.getTextOfJSDocComment(first.comment) ?? undefined
|
|
159
|
-
return undefined
|
|
160
|
-
}
|
package/runtime/mcp/manifest.ts
CHANGED
|
@@ -5,9 +5,10 @@ import type { JsonSchema } from './schema.ts'
|
|
|
5
5
|
export interface ToolSchema {
|
|
6
6
|
name: string
|
|
7
7
|
description?: string
|
|
8
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
|
|
9
|
+
path: string
|
|
8
10
|
inputSchema: JsonSchema
|
|
9
11
|
outputSchema?: JsonSchema
|
|
10
|
-
paramOrder: string[]
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface ResourceSchema {
|
package/runtime/mcp/server.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { dispatchAction } from '../routes.ts'
|
|
2
2
|
import type { FlatRoute, BrustRequest } from '../routes.ts'
|
|
3
|
+
import type { EndpointDef } from '../define-actions.ts'
|
|
3
4
|
import type { McpManifest } from './manifest.ts'
|
|
4
5
|
|
|
5
6
|
export interface McpServerOptions {
|
|
6
7
|
manifest: McpManifest
|
|
7
|
-
|
|
8
|
+
endpoints: EndpointDef[]
|
|
8
9
|
routes: FlatRoute[]
|
|
9
10
|
packageVersion?: string
|
|
10
11
|
}
|
|
@@ -33,6 +34,8 @@ export interface McpServer {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export function makeMcpServer(opts: McpServerOptions): McpServer {
|
|
37
|
+
const byId = new Map<string, EndpointDef>()
|
|
38
|
+
for (const e of opts.endpoints) byId.set(`${e.method} ${e.path}`, e)
|
|
36
39
|
return {
|
|
37
40
|
async handleRequest(jsonRpcBody, req): Promise<string> {
|
|
38
41
|
let rpc: JsonRpcRequest
|
|
@@ -52,7 +55,7 @@ export function makeMcpServer(opts: McpServerOptions): McpServer {
|
|
|
52
55
|
case 'tools/list':
|
|
53
56
|
return handleToolsList(rpc, opts)
|
|
54
57
|
case 'tools/call':
|
|
55
|
-
return handleToolsCall(rpc, opts, req)
|
|
58
|
+
return handleToolsCall(rpc, opts, req, byId)
|
|
56
59
|
case 'resources/list':
|
|
57
60
|
return handleResourcesList(rpc, opts)
|
|
58
61
|
case 'resources/read':
|
|
@@ -105,6 +108,7 @@ async function handleToolsCall(
|
|
|
105
108
|
rpc: JsonRpcRequest,
|
|
106
109
|
opts: McpServerOptions,
|
|
107
110
|
req: BrustRequest,
|
|
111
|
+
byId: Map<string, EndpointDef>,
|
|
108
112
|
): Promise<string> {
|
|
109
113
|
const params = rpc.params as { name?: string; arguments?: Record<string, unknown> } | undefined
|
|
110
114
|
if (!params || typeof params.name !== 'string') {
|
|
@@ -114,43 +118,30 @@ async function handleToolsCall(
|
|
|
114
118
|
if (!tool) {
|
|
115
119
|
return makeError(rpc.id ?? null, -32601, `unknown tool: ${params.name}`)
|
|
116
120
|
}
|
|
117
|
-
const
|
|
118
|
-
if (!
|
|
119
|
-
return makeError(rpc.id ?? null, -32603, `
|
|
121
|
+
const def = byId.get(`${tool.method} ${tool.path}`)
|
|
122
|
+
if (!def) {
|
|
123
|
+
return makeError(rpc.id ?? null, -32603, `endpoint not registered: ${tool.name}`)
|
|
120
124
|
}
|
|
121
|
-
|
|
122
|
-
const args = (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const terminal = async () => {
|
|
127
|
-
try {
|
|
128
|
-
const result = await action.fn(req, ...args)
|
|
129
|
-
return {
|
|
130
|
-
status: 200,
|
|
131
|
-
body: result === undefined ? '' : JSON.stringify(result),
|
|
132
|
-
contentType: 'application/json; charset=utf-8',
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
const e = err instanceof Error ? err : new Error(String(err))
|
|
136
|
-
return {
|
|
137
|
-
status: 500,
|
|
138
|
-
body: JSON.stringify({ error: { message: e.message, name: e.name } }),
|
|
139
|
-
contentType: 'application/json; charset=utf-8',
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const chain = composeChain(req, action.middleware, terminal)
|
|
144
|
-
const response = await chain()
|
|
145
|
-
if (response.status >= 400) {
|
|
146
|
-
return makeResult(rpc.id ?? null, {
|
|
147
|
-
content: [{ type: 'text', text: response.body }],
|
|
148
|
-
isError: true,
|
|
149
|
-
})
|
|
125
|
+
|
|
126
|
+
const args = (params.arguments ?? {}) as {
|
|
127
|
+
params?: Record<string, string>
|
|
128
|
+
query?: Record<string, string>
|
|
129
|
+
body?: unknown
|
|
150
130
|
}
|
|
131
|
+
const resp = await dispatchAction(
|
|
132
|
+
{
|
|
133
|
+
kind: 'action',
|
|
134
|
+
action_id: `${tool.method} ${tool.path}`,
|
|
135
|
+
content_type: 'application/json',
|
|
136
|
+
params: args.params ?? {},
|
|
137
|
+
body_text: args.body === undefined ? '' : JSON.stringify(args.body),
|
|
138
|
+
req: { ...req, search: args.query ?? {} } as BrustRequest,
|
|
139
|
+
} as never,
|
|
140
|
+
byId,
|
|
141
|
+
)
|
|
151
142
|
return makeResult(rpc.id ?? null, {
|
|
152
|
-
content: [{ type: 'text', text:
|
|
153
|
-
isError:
|
|
143
|
+
content: [{ type: 'text', text: resp.body || '' }],
|
|
144
|
+
isError: resp.status >= 400,
|
|
154
145
|
})
|
|
155
146
|
}
|
|
156
147
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const ENC = new TextEncoder()
|
|
2
|
+
|
|
3
|
+
let warned = false
|
|
4
|
+
|
|
5
|
+
/** @internal — used by tests to reset the warn-once flag. */
|
|
6
|
+
export function _resetWarnedForTests(): void {
|
|
7
|
+
warned = false
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Splice `snippet` (a full `<script>…</script>`) into `body` immediately
|
|
11
|
+
* before the first `</head>` (case-insensitive on the four ASCII letters
|
|
12
|
+
* only). Returns the original body untouched if `snippet` is null/empty or if
|
|
13
|
+
* `</head>` is absent. */
|
|
14
|
+
export function injectActionPrefix(body: Uint8Array, snippet: string | null): Uint8Array {
|
|
15
|
+
if (!snippet) return body
|
|
16
|
+
const pos = findHeadCloseTag(body)
|
|
17
|
+
if (pos < 0) {
|
|
18
|
+
if (!warned) {
|
|
19
|
+
console.warn('[brust] action-prefix: no </head> in first chunk; global not injected')
|
|
20
|
+
warned = true
|
|
21
|
+
}
|
|
22
|
+
return body
|
|
23
|
+
}
|
|
24
|
+
const tagBytes = ENC.encode(snippet)
|
|
25
|
+
const out = new Uint8Array(body.length + tagBytes.length)
|
|
26
|
+
out.set(body.subarray(0, pos), 0)
|
|
27
|
+
out.set(tagBytes, pos)
|
|
28
|
+
out.set(body.subarray(pos), pos + tagBytes.length)
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Module-scope snippet config: set once by run() when an app uses a custom
|
|
33
|
+
// (non-default) actionPrefix; read by the render stream on both paths.
|
|
34
|
+
let snippet: string | null = null
|
|
35
|
+
export function configureActionPrefixSnippet(s: string | null): void {
|
|
36
|
+
snippet = s
|
|
37
|
+
}
|
|
38
|
+
export function getActionPrefixSnippet(): string | null {
|
|
39
|
+
return snippet
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findHeadCloseTag(body: Uint8Array): number {
|
|
43
|
+
const LT = 0x3c,
|
|
44
|
+
SL = 0x2f,
|
|
45
|
+
GT = 0x3e
|
|
46
|
+
for (let i = 0, max = body.length - 6; i < max; i++) {
|
|
47
|
+
if (body[i] !== LT || body[i + 1] !== SL) continue
|
|
48
|
+
if (!isLetter(body[i + 2], 0x48)) continue
|
|
49
|
+
if (!isLetter(body[i + 3], 0x45)) continue
|
|
50
|
+
if (!isLetter(body[i + 4], 0x41)) continue
|
|
51
|
+
if (!isLetter(body[i + 5], 0x44)) continue
|
|
52
|
+
if (body[i + 6] !== GT) continue
|
|
53
|
+
return i
|
|
54
|
+
}
|
|
55
|
+
return -1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isLetter(b: number, u: number): boolean {
|
|
59
|
+
return b === u || b === (u | 0x20)
|
|
60
|
+
}
|
|
@@ -13,7 +13,7 @@ export function _resetWarnedForTests(): void {
|
|
|
13
13
|
* before the first occurrence of `</head>` (case-insensitive). Returns the
|
|
14
14
|
* original body untouched if `hrefs` is empty or if `</head>` is absent
|
|
15
15
|
* (warns once in the latter case). Renderer calls this on the first chunk
|
|
16
|
-
* only — see spec
|
|
16
|
+
* only — see spec S"SSR <link> injection". */
|
|
17
17
|
export function injectCssLink(body: Uint8Array, hrefs: readonly string[]): Uint8Array {
|
|
18
18
|
if (hrefs.length === 0) return body
|
|
19
19
|
const pos = findHeadCloseTag(body)
|
package/runtime/render/stream.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
|
|
|
9
9
|
import { injectCssLink } from './inject-css-link.ts'
|
|
10
10
|
import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
|
|
11
11
|
import { injectDevClient } from './inject-dev-client.ts'
|
|
12
|
+
import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
|
|
12
13
|
import { getDevClientSnippet } from '../dev/inject.ts'
|
|
13
14
|
|
|
14
15
|
export interface RenderBranchStreamingArgs {
|
|
@@ -148,6 +149,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
148
149
|
const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
|
|
149
150
|
body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
|
|
150
151
|
body = injectDevClient(body, getDevClientSnippet())
|
|
152
|
+
body = injectActionPrefix(body, getActionPrefixSnippet())
|
|
151
153
|
const meta = makeMeta({
|
|
152
154
|
status: successStatus,
|
|
153
155
|
streaming: false,
|
|
@@ -208,8 +210,9 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
208
210
|
.map((h) => `<link rel="stylesheet" href="${h}">`)
|
|
209
211
|
.join('')
|
|
210
212
|
const devTag = getDevClientSnippet() ?? ''
|
|
211
|
-
|
|
212
|
-
|
|
213
|
+
const prefixTag = getActionPrefixSnippet() ?? ''
|
|
214
|
+
if (linkTagsStr.length > 0 || devTag.length > 0 || prefixTag.length > 0) {
|
|
215
|
+
const prepend = encoder.encode(linkTagsStr + prefixTag + devTag)
|
|
213
216
|
const out = new Uint8Array(flushed.length + prepend.length)
|
|
214
217
|
out.set(flushed, 0)
|
|
215
218
|
out.set(prepend, flushed.length)
|