brustjs 0.1.0-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 +110 -0
- package/package.json +92 -0
- package/runtime/actions.ts +65 -0
- package/runtime/bun.lock +236 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
- package/runtime/cli/build.ts +252 -0
- package/runtime/cli/dev.ts +92 -0
- package/runtime/cli/index.ts +30 -0
- package/runtime/cli/native-routes-emit.ts +171 -0
- package/runtime/cli/native-shim-plugin.ts +85 -0
- package/runtime/cli/new.ts +208 -0
- package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
- package/runtime/cli/templates/minimal/_gitignore +4 -0
- package/runtime/cli/templates/minimal/app.css +6 -0
- package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
- package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
- package/runtime/cli/templates/minimal/index.ts +4 -0
- package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
- package/runtime/cli/templates/minimal/routes.tsx +6 -0
- package/runtime/cli/templates/minimal/tsconfig.json +20 -0
- package/runtime/client/index.ts +121 -0
- package/runtime/config.ts +148 -0
- package/runtime/css/build.ts +54 -0
- package/runtime/css/component-build.ts +78 -0
- package/runtime/css/component-loader.ts +27 -0
- package/runtime/css/manifest.ts +51 -0
- package/runtime/css/process-modules.ts +56 -0
- package/runtime/css/route-deps.ts +33 -0
- package/runtime/css/scan-imports.ts +79 -0
- package/runtime/css.ts +39 -0
- package/runtime/dev/client.ts +49 -0
- package/runtime/dev/coordinator.ts +127 -0
- package/runtime/dev/inject.ts +17 -0
- package/runtime/dev/tui.ts +109 -0
- package/runtime/dev/watcher.ts +109 -0
- package/runtime/dev/worker-registry.ts +96 -0
- package/runtime/dev/ws-channel.ts +99 -0
- package/runtime/index.d.ts +199 -0
- package/runtime/index.js +604 -0
- package/runtime/index.ts +618 -0
- package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
- package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
- package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
- package/runtime/islands/_entries/react-dom.ts +7 -0
- package/runtime/islands/_entries/react.ts +11 -0
- package/runtime/islands/bootstrap.ts +241 -0
- package/runtime/islands/build.ts +141 -0
- package/runtime/islands/importmap.ts +17 -0
- package/runtime/islands/island.tsx +58 -0
- package/runtime/islands/native-render.ts +153 -0
- package/runtime/mcp/extractor.ts +160 -0
- package/runtime/mcp/manifest.ts +50 -0
- package/runtime/mcp/schema.ts +124 -0
- package/runtime/mcp/server.ts +250 -0
- package/runtime/render/inject-css-link.ts +59 -0
- package/runtime/render/inject-dev-client.ts +49 -0
- package/runtime/render/stream.ts +304 -0
- package/runtime/routes.ts +1406 -0
- package/runtime/scan-actions.ts +172 -0
- package/runtime/sse/handler.ts +85 -0
- package/runtime/tsconfig.json +14 -0
- package/runtime/ws/handler.ts +151 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
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
|
+
import type { FlatRoute } from '../routes.ts'
|
|
6
|
+
|
|
7
|
+
export interface ExtractOptions {
|
|
8
|
+
/** Files that have 'use server' directive — provided by scanActions. */
|
|
9
|
+
serverFiles: string[]
|
|
10
|
+
/** The routes module file (e.g. example/hello-world/routes.tsx). */
|
|
11
|
+
routesFile: string
|
|
12
|
+
/** The user's source roots. Reserved for future tsconfig/baseUrl/paths
|
|
13
|
+
* resolution; currently ignored — pass at least one for forward compat. */
|
|
14
|
+
sourceRoots: string[]
|
|
15
|
+
/** Result of `await brust.scanActions(...)` — maps action ids to ActionDef. */
|
|
16
|
+
actions: ActionDef[]
|
|
17
|
+
/** Result of `defineRoutes(...)`. */
|
|
18
|
+
routes: FlatRoute[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManifest> {
|
|
22
|
+
const program = ts.createProgram({
|
|
23
|
+
rootNames: [...opts.serverFiles, opts.routesFile],
|
|
24
|
+
options: {
|
|
25
|
+
target: ts.ScriptTarget.ES2022,
|
|
26
|
+
module: ts.ModuleKind.ESNext,
|
|
27
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
28
|
+
jsx: ts.JsxEmit.Preserve,
|
|
29
|
+
allowJs: false,
|
|
30
|
+
noEmit: true,
|
|
31
|
+
skipLibCheck: true,
|
|
32
|
+
// Required: TS 5.x collapses string | null to plain string without strict
|
|
33
|
+
// null checks, which would silently drop the null variant from the schema.
|
|
34
|
+
strictNullChecks: true,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
const checker = program.getTypeChecker()
|
|
38
|
+
|
|
39
|
+
const tools: ToolSchema[] = []
|
|
40
|
+
const actionIds = new Set(opts.actions.map((a) => a.id))
|
|
41
|
+
|
|
42
|
+
for (const serverFile of opts.serverFiles) {
|
|
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
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resources = extractResources(checker, opts.routes, program.getSourceFile(opts.routesFile))
|
|
52
|
+
|
|
53
|
+
// Sort tools/resources alphabetically for stable manifest.
|
|
54
|
+
tools.sort((a, b) => a.name.localeCompare(b.name))
|
|
55
|
+
resources.sort((a, b) => a.uriTemplate.localeCompare(b.uriTemplate))
|
|
56
|
+
|
|
57
|
+
return { version: 1, tools, resources }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractToolFromNode(
|
|
61
|
+
checker: ts.TypeChecker,
|
|
62
|
+
node: ts.Node,
|
|
63
|
+
actionIds: Set<string>,
|
|
64
|
+
): ToolSchema | null {
|
|
65
|
+
// Case 1: export async function name(req, ...args)
|
|
66
|
+
if (
|
|
67
|
+
ts.isFunctionDeclaration(node) &&
|
|
68
|
+
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
|
69
|
+
node.name
|
|
70
|
+
) {
|
|
71
|
+
const name = node.name.text
|
|
72
|
+
if (!actionIds.has(name)) return null
|
|
73
|
+
return toolFromSignature(checker, name, node.parameters, node.type, getJsdoc(node))
|
|
74
|
+
}
|
|
75
|
+
// Case 2: export const name = withMiddleware([...], async (req, ...args) => ...)
|
|
76
|
+
if (
|
|
77
|
+
ts.isVariableStatement(node) &&
|
|
78
|
+
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
79
|
+
) {
|
|
80
|
+
for (const decl of node.declarationList.declarations) {
|
|
81
|
+
if (!ts.isIdentifier(decl.name)) continue
|
|
82
|
+
const name = decl.name.text
|
|
83
|
+
if (!actionIds.has(name)) continue
|
|
84
|
+
// Find the function expression inside withMiddleware(...).
|
|
85
|
+
const init = decl.initializer
|
|
86
|
+
if (init && ts.isCallExpression(init)) {
|
|
87
|
+
const fnArg = init.arguments[1]
|
|
88
|
+
if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) {
|
|
89
|
+
return toolFromSignature(checker, name, fnArg.parameters, fnArg.type, getJsdoc(node))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toolFromSignature(
|
|
98
|
+
checker: ts.TypeChecker,
|
|
99
|
+
name: string,
|
|
100
|
+
parameters: ts.NodeArray<ts.ParameterDeclaration>,
|
|
101
|
+
returnTypeNode: ts.TypeNode | undefined,
|
|
102
|
+
description: string | undefined,
|
|
103
|
+
): ToolSchema {
|
|
104
|
+
// Drop the first parameter (req: BrustRequest).
|
|
105
|
+
const argParams = parameters.slice(1)
|
|
106
|
+
const properties: Record<string, JsonSchema> = {}
|
|
107
|
+
const required: string[] = []
|
|
108
|
+
const paramOrder: string[] = []
|
|
109
|
+
for (const p of argParams) {
|
|
110
|
+
if (!ts.isIdentifier(p.name)) continue
|
|
111
|
+
const pname = p.name.text
|
|
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)
|
|
118
|
+
}
|
|
119
|
+
const inputSchema: JsonSchema = { type: 'object', properties }
|
|
120
|
+
if (required.length > 0) inputSchema.required = required
|
|
121
|
+
|
|
122
|
+
let outputSchema: JsonSchema | undefined
|
|
123
|
+
if (returnTypeNode) {
|
|
124
|
+
const returnType = checker.getTypeFromTypeNode(returnTypeNode)
|
|
125
|
+
outputSchema = tsTypeToJsonSchema(returnType, { checker, unwrapPromise: true })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { name, description, inputSchema, outputSchema, paramOrder }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractResources(
|
|
132
|
+
_checker: ts.TypeChecker,
|
|
133
|
+
routes: FlatRoute[],
|
|
134
|
+
_routesSourceFile: ts.SourceFile | undefined,
|
|
135
|
+
): ResourceSchema[] {
|
|
136
|
+
const out: ResourceSchema[] = []
|
|
137
|
+
for (let i = 0; i < routes.length; i++) {
|
|
138
|
+
const fr = routes[i]
|
|
139
|
+
const leaf = fr.chain[fr.chain.length - 1]
|
|
140
|
+
if (!leaf.loader) continue
|
|
141
|
+
// outputSchema extraction is best-effort — look up the loader's type via the source file.
|
|
142
|
+
// Skipped for MVP — leaves outputSchema undefined; MCP still works.
|
|
143
|
+
out.push({
|
|
144
|
+
uriTemplate: `brust://${fr.fullPath}`,
|
|
145
|
+
name: `loader for ${fr.fullPath}`,
|
|
146
|
+
routeIndex: i,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { JsonSchema } from './schema.ts'
|
|
4
|
+
|
|
5
|
+
export interface ToolSchema {
|
|
6
|
+
name: string
|
|
7
|
+
description?: string
|
|
8
|
+
inputSchema: JsonSchema
|
|
9
|
+
outputSchema?: JsonSchema
|
|
10
|
+
paramOrder: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResourceSchema {
|
|
14
|
+
uriTemplate: string
|
|
15
|
+
name: string
|
|
16
|
+
description?: string
|
|
17
|
+
outputSchema?: JsonSchema
|
|
18
|
+
routeIndex: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpManifest {
|
|
22
|
+
version: 1
|
|
23
|
+
tools: ToolSchema[]
|
|
24
|
+
resources: ResourceSchema[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MANIFEST_PATH = '.brust/mcp-manifest.json'
|
|
28
|
+
|
|
29
|
+
export async function writeManifest(cwd: string, m: McpManifest): Promise<void> {
|
|
30
|
+
const path = join(cwd, MANIFEST_PATH)
|
|
31
|
+
await mkdir(join(cwd, '.brust'), { recursive: true })
|
|
32
|
+
await Bun.write(path, JSON.stringify(m, null, 2))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readManifest(cwd: string): Promise<McpManifest | null> {
|
|
36
|
+
const path = join(cwd, MANIFEST_PATH)
|
|
37
|
+
const f = Bun.file(path)
|
|
38
|
+
if (!(await f.exists())) return null
|
|
39
|
+
const text = await f.text()
|
|
40
|
+
let parsed: unknown
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(text)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new Error(`mcp-manifest.json is malformed: ${e instanceof Error ? e.message : String(e)}`)
|
|
45
|
+
}
|
|
46
|
+
if (!parsed || typeof parsed !== 'object' || (parsed as McpManifest).version !== 1) {
|
|
47
|
+
throw new Error(`mcp-manifest.json version mismatch (expected 1)`)
|
|
48
|
+
}
|
|
49
|
+
return parsed as McpManifest
|
|
50
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
export interface JsonSchema {
|
|
4
|
+
type?: string | string[]
|
|
5
|
+
properties?: Record<string, JsonSchema>
|
|
6
|
+
required?: string[]
|
|
7
|
+
items?: JsonSchema
|
|
8
|
+
prefixItems?: JsonSchema[]
|
|
9
|
+
minItems?: number
|
|
10
|
+
maxItems?: number
|
|
11
|
+
additionalProperties?: JsonSchema | boolean
|
|
12
|
+
anyOf?: JsonSchema[]
|
|
13
|
+
enum?: unknown[]
|
|
14
|
+
format?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToJsonSchemaOptions {
|
|
18
|
+
unwrapPromise?: boolean
|
|
19
|
+
checker?: ts.TypeChecker // required for object/property iteration
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function tsTypeToJsonSchema(
|
|
23
|
+
type: ts.Type,
|
|
24
|
+
opts: ToJsonSchemaOptions = {},
|
|
25
|
+
): JsonSchema | undefined {
|
|
26
|
+
const flags = type.flags
|
|
27
|
+
// Unwrap Promise<T> at the top level when asked.
|
|
28
|
+
if (opts.unwrapPromise) {
|
|
29
|
+
const inner = unwrapPromise(type, opts.checker)
|
|
30
|
+
if (inner === null) return undefined // Promise<void>
|
|
31
|
+
if (inner !== undefined) return tsTypeToJsonSchema(inner, { ...opts, unwrapPromise: false })
|
|
32
|
+
}
|
|
33
|
+
// void → undefined (caller treats as "no schema")
|
|
34
|
+
if (flags & ts.TypeFlags.Void) return undefined
|
|
35
|
+
// any/unknown
|
|
36
|
+
if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) return {}
|
|
37
|
+
// null
|
|
38
|
+
if (flags & ts.TypeFlags.Null || flags & ts.TypeFlags.Undefined) return { type: 'null' }
|
|
39
|
+
// string literal
|
|
40
|
+
if (flags & ts.TypeFlags.StringLiteral)
|
|
41
|
+
return { type: 'string', enum: [(type as ts.StringLiteralType).value] }
|
|
42
|
+
// number literal
|
|
43
|
+
if (flags & ts.TypeFlags.NumberLiteral)
|
|
44
|
+
return { type: 'number', enum: [(type as ts.NumberLiteralType).value] }
|
|
45
|
+
// boolean literal
|
|
46
|
+
if (flags & ts.TypeFlags.BooleanLiteral) {
|
|
47
|
+
const v = (type as any).intrinsicName === 'true'
|
|
48
|
+
return { type: 'boolean', enum: [v] }
|
|
49
|
+
}
|
|
50
|
+
if (flags & ts.TypeFlags.String) return { type: 'string' }
|
|
51
|
+
if (flags & ts.TypeFlags.Number) return { type: 'number' }
|
|
52
|
+
if (flags & ts.TypeFlags.Boolean) return { type: 'boolean' }
|
|
53
|
+
// Union
|
|
54
|
+
if (type.isUnion()) {
|
|
55
|
+
return {
|
|
56
|
+
anyOf: type.types
|
|
57
|
+
.map((t) => tsTypeToJsonSchema(t, opts))
|
|
58
|
+
.filter((x): x is JsonSchema => x !== undefined),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Date special case
|
|
62
|
+
const symbol = type.getSymbol()
|
|
63
|
+
if (symbol?.name === 'Date') return { type: 'string', format: 'date-time' }
|
|
64
|
+
// Array / Tuple / Object
|
|
65
|
+
if (opts.checker) {
|
|
66
|
+
if (opts.checker.isArrayType?.(type)) {
|
|
67
|
+
const args = opts.checker.getTypeArguments(type as ts.TypeReference)
|
|
68
|
+
const inner = args[0]
|
|
69
|
+
return { type: 'array', items: tsTypeToJsonSchema(inner, opts) ?? {} }
|
|
70
|
+
}
|
|
71
|
+
// Tuple
|
|
72
|
+
if (opts.checker.isTupleType?.(type)) {
|
|
73
|
+
const args = opts.checker.getTypeArguments(type as ts.TypeReference) ?? []
|
|
74
|
+
const target = (type as any).target
|
|
75
|
+
return {
|
|
76
|
+
type: 'array',
|
|
77
|
+
prefixItems: args.map((t: ts.Type) => tsTypeToJsonSchema(t, opts) ?? {}),
|
|
78
|
+
minItems: target?.minLength ?? args.length,
|
|
79
|
+
maxItems: args.length,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Object: enumerate properties (or Record-like via string index signature)
|
|
83
|
+
if (flags & ts.TypeFlags.Object) {
|
|
84
|
+
const props = type.getProperties()
|
|
85
|
+
const stringIndex = type.getStringIndexType()
|
|
86
|
+
if (props.length === 0 && stringIndex) {
|
|
87
|
+
return { type: 'object', additionalProperties: tsTypeToJsonSchema(stringIndex, opts) ?? {} }
|
|
88
|
+
}
|
|
89
|
+
const properties: Record<string, JsonSchema> = {}
|
|
90
|
+
const required: string[] = []
|
|
91
|
+
for (const p of props) {
|
|
92
|
+
const decl = p.declarations?.[0]
|
|
93
|
+
if (!decl) continue
|
|
94
|
+
const propType = opts.checker.getTypeOfSymbolAtLocation(p, decl)
|
|
95
|
+
const propSchema = tsTypeToJsonSchema(propType, opts)
|
|
96
|
+
if (propSchema) {
|
|
97
|
+
properties[p.name] = propSchema
|
|
98
|
+
if (!(p.flags & ts.SymbolFlags.Optional)) {
|
|
99
|
+
required.push(p.name)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const out: JsonSchema = { type: 'object', properties }
|
|
104
|
+
if (required.length > 0) out.required = required
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Fallback: any (loses info but agent still works)
|
|
109
|
+
return {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function unwrapPromise(type: ts.Type, checker?: ts.TypeChecker): ts.Type | null | undefined {
|
|
113
|
+
// Returns: ts.Type unwrapped; null = unwrap result is void; undefined = not a Promise.
|
|
114
|
+
// getPromisedTypeOfPromise exists at runtime on every supported TS 5.x but is
|
|
115
|
+
// absent from the public TypeChecker typings — cast to access without losing
|
|
116
|
+
// strict-mode coverage elsewhere.
|
|
117
|
+
if (!checker) return undefined
|
|
118
|
+
const inner = (
|
|
119
|
+
checker as unknown as { getPromisedTypeOfPromise(t: ts.Type): ts.Type | undefined }
|
|
120
|
+
).getPromisedTypeOfPromise(type)
|
|
121
|
+
if (inner === undefined) return undefined
|
|
122
|
+
if (inner.flags & ts.TypeFlags.Void) return null
|
|
123
|
+
return inner
|
|
124
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ActionDef } from '../actions.ts'
|
|
2
|
+
import type { FlatRoute, BrustRequest } from '../routes.ts'
|
|
3
|
+
import type { McpManifest } from './manifest.ts'
|
|
4
|
+
|
|
5
|
+
export interface McpServerOptions {
|
|
6
|
+
manifest: McpManifest
|
|
7
|
+
actions: ActionDef[]
|
|
8
|
+
routes: FlatRoute[]
|
|
9
|
+
packageVersion?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface JsonRpcRequest {
|
|
13
|
+
jsonrpc: '2.0'
|
|
14
|
+
id?: number | string | null
|
|
15
|
+
method: string
|
|
16
|
+
params?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface JsonRpcSuccess<T> {
|
|
20
|
+
jsonrpc: '2.0'
|
|
21
|
+
id: number | string | null
|
|
22
|
+
result: T
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface JsonRpcError {
|
|
26
|
+
jsonrpc: '2.0'
|
|
27
|
+
id: number | string | null
|
|
28
|
+
error: { code: number; message: string; data?: unknown }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface McpServer {
|
|
32
|
+
handleRequest(jsonRpcBody: string, req: BrustRequest): Promise<string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function makeMcpServer(opts: McpServerOptions): McpServer {
|
|
36
|
+
return {
|
|
37
|
+
async handleRequest(jsonRpcBody, req): Promise<string> {
|
|
38
|
+
let rpc: JsonRpcRequest
|
|
39
|
+
try {
|
|
40
|
+
rpc = JSON.parse(jsonRpcBody)
|
|
41
|
+
} catch {
|
|
42
|
+
return makeError(null, -32700, 'Parse error')
|
|
43
|
+
}
|
|
44
|
+
if (rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') {
|
|
45
|
+
return makeError(rpc.id ?? null, -32600, 'Invalid Request')
|
|
46
|
+
}
|
|
47
|
+
switch (rpc.method) {
|
|
48
|
+
case 'initialize':
|
|
49
|
+
return handleInitialize(rpc, opts)
|
|
50
|
+
case 'notifications/initialized':
|
|
51
|
+
return '' // No response for notifications.
|
|
52
|
+
case 'tools/list':
|
|
53
|
+
return handleToolsList(rpc, opts)
|
|
54
|
+
case 'tools/call':
|
|
55
|
+
return handleToolsCall(rpc, opts, req)
|
|
56
|
+
case 'resources/list':
|
|
57
|
+
return handleResourcesList(rpc, opts)
|
|
58
|
+
case 'resources/read':
|
|
59
|
+
return handleResourcesRead(rpc, opts, req)
|
|
60
|
+
case 'prompts/list':
|
|
61
|
+
return makeResult(rpc.id ?? null, { prompts: [] })
|
|
62
|
+
case 'prompts/get':
|
|
63
|
+
return makeError(rpc.id ?? null, -32601, 'no prompts configured')
|
|
64
|
+
case 'logging/setLevel':
|
|
65
|
+
// Accept the level; no notifications emitted (SSE deferred).
|
|
66
|
+
return makeResult(rpc.id ?? null, {})
|
|
67
|
+
case 'notifications/roots/list_changed':
|
|
68
|
+
// No-op accept (it's a notification, no response).
|
|
69
|
+
return ''
|
|
70
|
+
default:
|
|
71
|
+
return makeError(rpc.id ?? null, -32601, `method not found: ${rpc.method}`)
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleInitialize(rpc: JsonRpcRequest, opts: McpServerOptions): string {
|
|
78
|
+
return makeResult(rpc.id ?? null, {
|
|
79
|
+
protocolVersion: '2025-06-18',
|
|
80
|
+
capabilities: {
|
|
81
|
+
tools: {},
|
|
82
|
+
resources: {},
|
|
83
|
+
prompts: {},
|
|
84
|
+
logging: {},
|
|
85
|
+
// roots is a client capability; not declared by server.
|
|
86
|
+
},
|
|
87
|
+
serverInfo: {
|
|
88
|
+
name: 'brust',
|
|
89
|
+
version: opts.packageVersion ?? '0.1.0',
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function handleToolsList(rpc: JsonRpcRequest, opts: McpServerOptions): Promise<string> {
|
|
95
|
+
const tools = opts.manifest.tools.map((t) => ({
|
|
96
|
+
name: t.name,
|
|
97
|
+
description: t.description,
|
|
98
|
+
inputSchema: t.inputSchema,
|
|
99
|
+
outputSchema: t.outputSchema,
|
|
100
|
+
}))
|
|
101
|
+
return makeResult(rpc.id ?? null, { tools })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handleToolsCall(
|
|
105
|
+
rpc: JsonRpcRequest,
|
|
106
|
+
opts: McpServerOptions,
|
|
107
|
+
req: BrustRequest,
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
const params = rpc.params as { name?: string; arguments?: Record<string, unknown> } | undefined
|
|
110
|
+
if (!params || typeof params.name !== 'string') {
|
|
111
|
+
return makeError(rpc.id ?? null, -32602, 'tools/call: name required')
|
|
112
|
+
}
|
|
113
|
+
const tool = opts.manifest.tools.find((t) => t.name === params.name)
|
|
114
|
+
if (!tool) {
|
|
115
|
+
return makeError(rpc.id ?? null, -32601, `unknown tool: ${params.name}`)
|
|
116
|
+
}
|
|
117
|
+
const action = opts.actions.find((a) => a.id === params.name)
|
|
118
|
+
if (!action) {
|
|
119
|
+
return makeError(rpc.id ?? null, -32603, `action not registered: ${params.name}`)
|
|
120
|
+
}
|
|
121
|
+
// Map { argsObject } → positional args via paramOrder.
|
|
122
|
+
const args = (tool.paramOrder ?? []).map((k) => params.arguments?.[k])
|
|
123
|
+
|
|
124
|
+
// Run through middleware chain (same shape as actionBranch).
|
|
125
|
+
const { composeChain } = await import('../routes.ts')
|
|
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
|
+
})
|
|
150
|
+
}
|
|
151
|
+
return makeResult(rpc.id ?? null, {
|
|
152
|
+
content: [{ type: 'text', text: response.body || '' }],
|
|
153
|
+
isError: false,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleResourcesList(rpc: JsonRpcRequest, opts: McpServerOptions): Promise<string> {
|
|
158
|
+
const resources = opts.manifest.resources.map((r) => ({
|
|
159
|
+
uri: r.uriTemplate,
|
|
160
|
+
name: r.name,
|
|
161
|
+
description: r.description,
|
|
162
|
+
mimeType: 'application/json',
|
|
163
|
+
}))
|
|
164
|
+
return makeResult(rpc.id ?? null, { resources })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleResourcesRead(
|
|
168
|
+
rpc: JsonRpcRequest,
|
|
169
|
+
opts: McpServerOptions,
|
|
170
|
+
req: BrustRequest,
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
const params = rpc.params as { uri?: string } | undefined
|
|
173
|
+
if (!params || typeof params.uri !== 'string') {
|
|
174
|
+
return makeError(rpc.id ?? null, -32602, 'resources/read: uri required')
|
|
175
|
+
}
|
|
176
|
+
// Strip the brust:// scheme.
|
|
177
|
+
const prefix = 'brust://'
|
|
178
|
+
if (!params.uri.startsWith(prefix)) {
|
|
179
|
+
return makeError(rpc.id ?? null, -32602, `unsupported URI scheme: ${params.uri}`)
|
|
180
|
+
}
|
|
181
|
+
const requestedPath = params.uri.slice(prefix.length)
|
|
182
|
+
|
|
183
|
+
// Match against the routes. matchUriPath does the {param} capture that the
|
|
184
|
+
// manifest's pre-computed ResourceSchema.routeIndex cannot do alone — both
|
|
185
|
+
// index spaces stay aligned because the extractor builds resources in the
|
|
186
|
+
// same opts.routes order the server consumes, so a divergence would mean
|
|
187
|
+
// routes were rebuilt without a matching brust.buildMcpManifest call.
|
|
188
|
+
const match = matchUriPath(requestedPath, opts.routes)
|
|
189
|
+
if (!match) {
|
|
190
|
+
return makeError(rpc.id ?? null, -32601, `no route matches URI ${params.uri}`)
|
|
191
|
+
}
|
|
192
|
+
const route = opts.routes[match.routeIndex]
|
|
193
|
+
const leaf = route.chain[route.chain.length - 1]
|
|
194
|
+
if (!leaf.loader) {
|
|
195
|
+
return makeError(rpc.id ?? null, -32603, `route ${route.fullPath} has no loader`)
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const data = await leaf.loader({ params: match.params, path: requestedPath, req })
|
|
199
|
+
return makeResult(rpc.id ?? null, {
|
|
200
|
+
contents: [
|
|
201
|
+
{
|
|
202
|
+
uri: params.uri,
|
|
203
|
+
mimeType: 'application/json',
|
|
204
|
+
text: JSON.stringify(data),
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
})
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
210
|
+
return makeError(rpc.id ?? null, -32603, `loader error: ${msg}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface UriMatch {
|
|
215
|
+
routeIndex: number
|
|
216
|
+
params: Record<string, string>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function matchUriPath(requestedPath: string, routes: FlatRoute[]): UriMatch | null {
|
|
220
|
+
// Simple param matching: split both into segments and match.
|
|
221
|
+
const reqSegs = requestedPath.split('/').filter((s) => s.length > 0)
|
|
222
|
+
for (let i = 0; i < routes.length; i++) {
|
|
223
|
+
const fr = routes[i]
|
|
224
|
+
const routeSegs = fr.fullPath.split('/').filter((s) => s.length > 0)
|
|
225
|
+
if (reqSegs.length !== routeSegs.length) continue
|
|
226
|
+
const params: Record<string, string> = {}
|
|
227
|
+
let matched = true
|
|
228
|
+
for (let j = 0; j < reqSegs.length; j++) {
|
|
229
|
+
const rseg = routeSegs[j]
|
|
230
|
+
if (rseg.startsWith('{') && rseg.endsWith('}')) {
|
|
231
|
+
params[rseg.slice(1, -1)] = reqSegs[j]
|
|
232
|
+
} else if (rseg !== reqSegs[j]) {
|
|
233
|
+
matched = false
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (matched) return { routeIndex: i, params }
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function makeResult(id: number | string | null, result: unknown): string {
|
|
243
|
+
const resp: JsonRpcSuccess<unknown> = { jsonrpc: '2.0', id, result }
|
|
244
|
+
return JSON.stringify(resp)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function makeError(id: number | string | null, code: number, message: string): string {
|
|
248
|
+
const resp: JsonRpcError = { jsonrpc: '2.0', id, error: { code, message } }
|
|
249
|
+
return JSON.stringify(resp)
|
|
250
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const ENC = new TextEncoder()
|
|
2
|
+
|
|
3
|
+
/** Set to true on the first miss; suppresses subsequent warnings so a
|
|
4
|
+
* misconfigured Layout doesn't flood logs. Test helper resets this. */
|
|
5
|
+
let warned = false
|
|
6
|
+
|
|
7
|
+
/** @internal — used by the unit test suite to reset the warn-once flag. */
|
|
8
|
+
export function _resetWarnedForTests(): void {
|
|
9
|
+
warned = false
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Splice `<link rel="stylesheet" href="...">` tags into `body` immediately
|
|
13
|
+
* before the first occurrence of `</head>` (case-insensitive). Returns the
|
|
14
|
+
* original body untouched if `hrefs` is empty or if `</head>` is absent
|
|
15
|
+
* (warns once in the latter case). Renderer calls this on the first chunk
|
|
16
|
+
* only — see spec §"SSR <link> injection". */
|
|
17
|
+
export function injectCssLink(body: Uint8Array, hrefs: readonly string[]): Uint8Array {
|
|
18
|
+
if (hrefs.length === 0) return body
|
|
19
|
+
const pos = findHeadCloseTag(body)
|
|
20
|
+
if (pos < 0) {
|
|
21
|
+
if (!warned) {
|
|
22
|
+
console.warn('[brust] css: no </head> in first chunk; <link> not injected')
|
|
23
|
+
warned = true
|
|
24
|
+
}
|
|
25
|
+
return body
|
|
26
|
+
}
|
|
27
|
+
const tags = hrefs.map((h) => `<link rel="stylesheet" href="${h}">`).join('')
|
|
28
|
+
const tagsBytes = ENC.encode(tags)
|
|
29
|
+
const out = new Uint8Array(body.length + tagsBytes.length)
|
|
30
|
+
out.set(body.subarray(0, pos), 0)
|
|
31
|
+
out.set(tagsBytes, pos)
|
|
32
|
+
out.set(body.subarray(pos), pos + tagsBytes.length)
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Byte-level scan for `</head>` (case-insensitive on the four letters).
|
|
37
|
+
* Returns the byte offset of the `<` or -1 if not found. */
|
|
38
|
+
function findHeadCloseTag(body: Uint8Array): number {
|
|
39
|
+
// Target bytes: `<` `/` H E A D `>` — 7 bytes total.
|
|
40
|
+
// We only case-fold the four ASCII letters; the angle/slash bytes are exact.
|
|
41
|
+
const LT = 0x3c // <
|
|
42
|
+
const SL = 0x2f // /
|
|
43
|
+
const GT = 0x3e // >
|
|
44
|
+
for (let i = 0, max = body.length - 6; i < max; i++) {
|
|
45
|
+
if (body[i] !== LT || body[i + 1] !== SL) continue
|
|
46
|
+
if (!isLetter(body[i + 2], 0x48)) continue // H/h
|
|
47
|
+
if (!isLetter(body[i + 3], 0x45)) continue // E/e
|
|
48
|
+
if (!isLetter(body[i + 4], 0x41)) continue // A/a
|
|
49
|
+
if (!isLetter(body[i + 5], 0x44)) continue // D/d
|
|
50
|
+
if (body[i + 6] !== GT) continue
|
|
51
|
+
return i
|
|
52
|
+
}
|
|
53
|
+
return -1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Returns true if `b` matches the upper-case letter `u` (b === u || b === u|0x20). */
|
|
57
|
+
function isLetter(b: number, u: number): boolean {
|
|
58
|
+
return b === u || b === (u | 0x20)
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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` into `body` immediately before the first `</head>`
|
|
11
|
+
* (case-insensitive on the four ASCII letters only). Returns the original body
|
|
12
|
+
* untouched if `snippet` is null/empty or if `</head>` is absent. */
|
|
13
|
+
export function injectDevClient(body: Uint8Array, snippet: string | null): Uint8Array {
|
|
14
|
+
if (!snippet) return body
|
|
15
|
+
const pos = findHeadCloseTag(body)
|
|
16
|
+
if (pos < 0) {
|
|
17
|
+
if (!warned) {
|
|
18
|
+
console.warn('[brust] dev: no </head> in first chunk; dev-client <script> not injected')
|
|
19
|
+
warned = true
|
|
20
|
+
}
|
|
21
|
+
return body
|
|
22
|
+
}
|
|
23
|
+
const tagBytes = ENC.encode(snippet)
|
|
24
|
+
const out = new Uint8Array(body.length + tagBytes.length)
|
|
25
|
+
out.set(body.subarray(0, pos), 0)
|
|
26
|
+
out.set(tagBytes, pos)
|
|
27
|
+
out.set(body.subarray(pos), pos + tagBytes.length)
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findHeadCloseTag(body: Uint8Array): number {
|
|
32
|
+
const LT = 0x3c,
|
|
33
|
+
SL = 0x2f,
|
|
34
|
+
GT = 0x3e
|
|
35
|
+
for (let i = 0, max = body.length - 6; i < max; i++) {
|
|
36
|
+
if (body[i] !== LT || body[i + 1] !== SL) continue
|
|
37
|
+
if (!isLetter(body[i + 2], 0x48)) continue
|
|
38
|
+
if (!isLetter(body[i + 3], 0x45)) continue
|
|
39
|
+
if (!isLetter(body[i + 4], 0x41)) continue
|
|
40
|
+
if (!isLetter(body[i + 5], 0x44)) continue
|
|
41
|
+
if (body[i + 6] !== GT) continue
|
|
42
|
+
return i
|
|
43
|
+
}
|
|
44
|
+
return -1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isLetter(b: number, u: number): boolean {
|
|
48
|
+
return b === u || b === (u | 0x20)
|
|
49
|
+
}
|