okengine 0.1.6 → 0.2.2
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/AGENTS.md +1 -1
- package/README.md +36 -29
- package/docs/spec/example.md +1187 -0
- package/docs/spec/unified-theory.md +1 -1
- package/package.json +20 -6
- package/src/cli/dev-app-runner.ts +6 -2
- package/src/cli/dev.ts +19 -10
- package/src/cli/doc-drift.ts +54 -21
- package/src/console/index.ts +25 -13
- package/src/console/server/app.ts +44 -7
- package/src/console/server/claim.test.ts +7 -4
- package/src/console/server/claim.ts +2 -7
- package/src/console/server/flows.ts +2 -6
- package/src/console/server/index.ts +2 -1
- package/src/console/server/lazy-panels.test.ts +27 -0
- package/src/console/server/panel-load.ts +28 -0
- package/src/console/server/plugin.ts +1 -1
- package/src/console/server/plugins.ts +7 -6
- package/src/console/server/public-flows.ts +12 -0
- package/src/console/server/state.ts +159 -122
- package/src/console/server/store.ts +13 -10
- package/src/console/ui/dist/assets/index-B71Yl_SS.js +10 -0
- package/src/console/ui/dist/assets/panel-access-Dd37LU2c.js +64 -0
- package/src/console/ui/dist/assets/panel-ai-CC7LR6-J.js +1 -0
- package/src/console/ui/dist/assets/panel-architecture-B5b3iKCz.js +1 -0
- package/src/console/ui/dist/assets/panel-channels-CeNjTKXp.js +1 -0
- package/src/console/ui/dist/assets/panel-clock-DgFTLoHV.js +1 -0
- package/src/console/ui/dist/assets/panel-diff-DxehccqB.js +1 -0
- package/src/console/ui/dist/assets/panel-flows-BtrVn-Eg.js +45 -0
- package/src/console/ui/dist/assets/panel-gates-Z9MKRGdH.js +1 -0
- package/src/console/ui/dist/assets/panel-overview-Bd48d9km.js +1 -0
- package/src/console/ui/dist/assets/panel-plugins-DWd0TowH.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BwsWqKeB.js +1 -0
- package/src/console/ui/dist/assets/panel-signals-9najbZY2.js +1 -0
- package/src/console/ui/dist/assets/panel-store-OHkP2pDp.js +1 -0
- package/src/console/ui/dist/assets/panel-traces-tn2JoY8U.js +1 -0
- package/src/console/ui/dist/assets/panel-vault-BbfWdox0.js +1 -0
- package/src/console/ui/dist/assets/rolldown-runtime-CNC7AqOf.js +1 -0
- package/src/console/ui/dist/assets/style-Cnl7WLya.css +3 -0
- package/src/console/ui/dist/index.html +14 -0
- package/src/docs-origin.ts +19 -0
- package/src/drivers/index.ts +1 -6
- package/src/drivers/vault-sops.ts +20 -1
- package/src/kernel/app.ts +1 -1
- package/src/kernel/boot-bind/ai.ts +31 -0
- package/src/kernel/boot-bind/channel.ts +27 -0
- package/src/kernel/boot-bind/clock.ts +74 -0
- package/src/kernel/boot-bind/gate.ts +28 -0
- package/src/kernel/boot-bind/runs.ts +28 -0
- package/src/kernel/boot-bind/signal.ts +68 -0
- package/src/kernel/boot-bind/store.ts +47 -0
- package/src/kernel/boot-bind/vault.ts +36 -0
- package/src/kernel/boot.test.ts +85 -1
- package/src/kernel/boot.ts +250 -212
- package/src/kernel/errors.registry.test.ts +4 -2
- package/src/kernel/errors.ts +3 -1
- package/src/kernel/fx.test.ts +2 -2
- package/src/kernel/index.ts +2 -0
- package/src/mcp/data.ts +1 -0
- package/src/mcp/docs-index.ts +252 -0
- package/src/mcp/docs-mcp.test.ts +176 -0
- package/src/mcp/docs-server.ts +233 -0
- package/src/mcp/docs-tools.ts +143 -0
- package/src/mcp/index.ts +29 -5
- package/src/mcp/protocol.ts +2 -1
- package/src/release/exports.test.ts +71 -0
- package/src/release/exports.ts +156 -0
- package/src/release/index.ts +21 -0
- package/src/release/limits.ts +9 -0
- package/src/release/measure.exports.test.ts +82 -0
- package/src/release/measure.ts +297 -14
- package/src/release/publish.ts +14 -3
- package/src/release/readme.test.ts +61 -0
- package/src/release/readme.ts +13 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/security.test.ts +6 -1
- package/src/runtime/types.ts +6 -0
- package/src/term.test.ts +51 -0
- package/src/term.ts +158 -0
- package/src/test/create-test-app.ts +2 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only docs index for the docs MCP surface.
|
|
3
|
+
*
|
|
4
|
+
* Loads generated pages under `site/content/docs` (same tree as the docs site).
|
|
5
|
+
* Bodies are YAML-frontmatter-stripped so `oke.docs.get` matches Copy Markdown.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdir } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** One indexed documentation page. */
|
|
12
|
+
export interface DocsPage {
|
|
13
|
+
/** URL slug under `/docs` (`""` for the index page). */
|
|
14
|
+
readonly slug: string;
|
|
15
|
+
/** Content-relative path (e.g. `get-started/introduction.mdx`). */
|
|
16
|
+
readonly path: string;
|
|
17
|
+
/** Frontmatter title. */
|
|
18
|
+
readonly title: string;
|
|
19
|
+
/** Frontmatter description when present. */
|
|
20
|
+
readonly description: string;
|
|
21
|
+
/** Docs site URL (`/docs/...`). */
|
|
22
|
+
readonly url: string;
|
|
23
|
+
/** Markdown body with YAML frontmatter removed. */
|
|
24
|
+
readonly body: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Search hit returned by {@link DocsIndex.search}. */
|
|
28
|
+
export interface DocsSearchHit {
|
|
29
|
+
readonly slug: string;
|
|
30
|
+
readonly title: string;
|
|
31
|
+
readonly url: string;
|
|
32
|
+
readonly excerpt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** In-memory docs catalogue. */
|
|
36
|
+
export interface DocsIndex {
|
|
37
|
+
/** All loaded pages. */
|
|
38
|
+
readonly pages: readonly DocsPage[];
|
|
39
|
+
/**
|
|
40
|
+
* Look up a page by slug or content path.
|
|
41
|
+
*
|
|
42
|
+
* @param id - Slug (`get-started/introduction`) or path (`…/introduction.mdx`)
|
|
43
|
+
*/
|
|
44
|
+
readonly get: (id: string) => DocsPage | null;
|
|
45
|
+
/**
|
|
46
|
+
* Case-insensitive search over title, description, and body.
|
|
47
|
+
*
|
|
48
|
+
* @param query - Search string
|
|
49
|
+
* @param limit - Max hits (default 20)
|
|
50
|
+
*/
|
|
51
|
+
readonly search: (query: string, limit?: number) => readonly DocsSearchHit[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strip a leading YAML frontmatter block (`---` … `---`).
|
|
56
|
+
*
|
|
57
|
+
* @param raw - Full file contents
|
|
58
|
+
*/
|
|
59
|
+
export function stripYamlFrontmatter(raw: string): string {
|
|
60
|
+
const normalized = raw.replace(/\r\n/g, "\n");
|
|
61
|
+
if (!normalized.startsWith("---\n")) {
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
65
|
+
if (end < 0) {
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
return normalized.slice(end + "\n---\n".length).replace(/^\n/, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse `title` / `description` from a YAML frontmatter block.
|
|
73
|
+
*
|
|
74
|
+
* @param raw - Full file contents
|
|
75
|
+
*/
|
|
76
|
+
export function parseDocsFrontmatter(raw: string): {
|
|
77
|
+
readonly title: string;
|
|
78
|
+
readonly description: string;
|
|
79
|
+
} {
|
|
80
|
+
const normalized = raw.replace(/\r\n/g, "\n");
|
|
81
|
+
if (!normalized.startsWith("---\n")) {
|
|
82
|
+
return { title: "", description: "" };
|
|
83
|
+
}
|
|
84
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
85
|
+
if (end < 0) {
|
|
86
|
+
return { title: "", description: "" };
|
|
87
|
+
}
|
|
88
|
+
const block = normalized.slice(4, end);
|
|
89
|
+
let title = "";
|
|
90
|
+
let description = "";
|
|
91
|
+
for (const line of block.split("\n")) {
|
|
92
|
+
const titleMatch = /^title:\s*(.*)$/.exec(line);
|
|
93
|
+
if (titleMatch) {
|
|
94
|
+
title = unquoteYaml(titleMatch[1] ?? "");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const descMatch = /^description:\s*(.*)$/.exec(line);
|
|
98
|
+
if (descMatch) {
|
|
99
|
+
description = unquoteYaml(descMatch[1] ?? "");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { title, description };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param value - YAML scalar (optionally quoted)
|
|
107
|
+
*/
|
|
108
|
+
function unquoteYaml(value: string): string {
|
|
109
|
+
const trimmed = value.trim();
|
|
110
|
+
if (
|
|
111
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
112
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
113
|
+
) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(trimmed.startsWith("'")
|
|
116
|
+
? `"${trimmed.slice(1, -1).replace(/"/g, '\\"')}"`
|
|
117
|
+
: trimmed) as string;
|
|
118
|
+
} catch {
|
|
119
|
+
return trimmed.slice(1, -1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return trimmed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Default absolute path to `site/content/docs` from the monorepo root.
|
|
127
|
+
*/
|
|
128
|
+
export function defaultDocsContentDir(): string {
|
|
129
|
+
return join(import.meta.dir, "..", "..", "site", "content", "docs");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Load every `.md` / `.mdx` file under a docs content directory into an index.
|
|
134
|
+
*
|
|
135
|
+
* @param contentDir - Absolute `content/docs` directory
|
|
136
|
+
*/
|
|
137
|
+
export async function loadDocsIndex(
|
|
138
|
+
contentDir: string = defaultDocsContentDir(),
|
|
139
|
+
): Promise<DocsIndex> {
|
|
140
|
+
const files = await listDocsSourceFiles(contentDir);
|
|
141
|
+
const pages: DocsPage[] = [];
|
|
142
|
+
|
|
143
|
+
for (const abs of files) {
|
|
144
|
+
const relative = abs.slice(contentDir.length + 1).replaceAll("\\", "/");
|
|
145
|
+
const raw = await Bun.file(abs).text();
|
|
146
|
+
const { title, description } = parseDocsFrontmatter(raw);
|
|
147
|
+
const body = stripYamlFrontmatter(raw);
|
|
148
|
+
const slug = pathToSlug(relative);
|
|
149
|
+
pages.push({
|
|
150
|
+
slug,
|
|
151
|
+
path: relative,
|
|
152
|
+
title: title || slug || "Documentation",
|
|
153
|
+
description,
|
|
154
|
+
url: slug.length === 0 ? "/docs" : `/docs/${slug}`,
|
|
155
|
+
body,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pages.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
160
|
+
|
|
161
|
+
const bySlug = new Map(pages.map((p) => [p.slug, p]));
|
|
162
|
+
const byPath = new Map(pages.map((p) => [p.path, p]));
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
pages,
|
|
166
|
+
get(id) {
|
|
167
|
+
const normalized = id
|
|
168
|
+
.replace(/^\/docs\/?/, "")
|
|
169
|
+
.replace(/^\//, "")
|
|
170
|
+
.replace(/\/$/, "");
|
|
171
|
+
if (bySlug.has(normalized)) return bySlug.get(normalized) ?? null;
|
|
172
|
+
if (byPath.has(normalized)) return byPath.get(normalized) ?? null;
|
|
173
|
+
if (byPath.has(`${normalized}.mdx`)) {
|
|
174
|
+
return byPath.get(`${normalized}.mdx`) ?? null;
|
|
175
|
+
}
|
|
176
|
+
if (byPath.has(`${normalized}.md`)) {
|
|
177
|
+
return byPath.get(`${normalized}.md`) ?? null;
|
|
178
|
+
}
|
|
179
|
+
if (normalized === "docs" || normalized === "") {
|
|
180
|
+
return bySlug.get("") ?? null;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
},
|
|
184
|
+
search(query, limit = 20) {
|
|
185
|
+
const q = query.trim().toLowerCase();
|
|
186
|
+
if (q.length === 0) return [];
|
|
187
|
+
const hits: DocsSearchHit[] = [];
|
|
188
|
+
for (const page of pages) {
|
|
189
|
+
const hay = `${page.title}\n${page.description}\n${page.body}`.toLowerCase();
|
|
190
|
+
const idx = hay.indexOf(q);
|
|
191
|
+
if (idx < 0) continue;
|
|
192
|
+
hits.push({
|
|
193
|
+
slug: page.slug,
|
|
194
|
+
title: page.title,
|
|
195
|
+
url: page.url,
|
|
196
|
+
excerpt: excerptAround(page.body, q, idx),
|
|
197
|
+
});
|
|
198
|
+
if (hits.length >= limit) break;
|
|
199
|
+
}
|
|
200
|
+
return hits;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param relativePath - e.g. `get-started/introduction.mdx`
|
|
207
|
+
*/
|
|
208
|
+
function pathToSlug(relativePath: string): string {
|
|
209
|
+
const noExt = relativePath.replace(/\.mdx?$/i, "");
|
|
210
|
+
if (noExt === "index") return "";
|
|
211
|
+
if (noExt.endsWith("/index")) {
|
|
212
|
+
return noExt.slice(0, -"/index".length);
|
|
213
|
+
}
|
|
214
|
+
return noExt;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param dir - Absolute directory
|
|
219
|
+
*/
|
|
220
|
+
async function listDocsSourceFiles(dir: string): Promise<string[]> {
|
|
221
|
+
const out: string[] = [];
|
|
222
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const abs = join(dir, entry.name);
|
|
225
|
+
if (entry.isDirectory()) {
|
|
226
|
+
out.push(...(await listDocsSourceFiles(abs)));
|
|
227
|
+
} else if (
|
|
228
|
+
entry.isFile() &&
|
|
229
|
+
(entry.name.endsWith(".md") || entry.name.endsWith(".mdx"))
|
|
230
|
+
) {
|
|
231
|
+
out.push(abs);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param body - Page body
|
|
239
|
+
* @param query - Lowercase query
|
|
240
|
+
* @param idx - Match index in lowercased haystack (approx)
|
|
241
|
+
*/
|
|
242
|
+
function excerptAround(body: string, query: string, _idx: number): string {
|
|
243
|
+
const lower = body.toLowerCase();
|
|
244
|
+
const at = lower.indexOf(query);
|
|
245
|
+
if (at < 0) {
|
|
246
|
+
return body.slice(0, 160).trim();
|
|
247
|
+
}
|
|
248
|
+
const start = Math.max(0, at - 60);
|
|
249
|
+
const end = Math.min(body.length, at + query.length + 100);
|
|
250
|
+
const slice = body.slice(start, end).replace(/\s+/g, " ").trim();
|
|
251
|
+
return `${start > 0 ? "…" : ""}${slice}${end < body.length ? "…" : ""}`;
|
|
252
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docs MCP acceptance:
|
|
3
|
+
* - Host/Origin validation (same class as app MCP)
|
|
4
|
+
* - tools/list exposes only search + get
|
|
5
|
+
* - oke.docs.get matches on-disk source (frontmatter stripped)
|
|
6
|
+
* - search finds a known page
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { isDataEnvelope } from "./data.ts";
|
|
12
|
+
import {
|
|
13
|
+
defaultDocsContentDir,
|
|
14
|
+
loadDocsIndex,
|
|
15
|
+
stripYamlFrontmatter,
|
|
16
|
+
} from "./docs-index.ts";
|
|
17
|
+
import { createDocsMcpServer } from "./docs-server.ts";
|
|
18
|
+
import { createMcpServer } from "./server.ts";
|
|
19
|
+
import { createSessionStore } from "../auth/sessions.ts";
|
|
20
|
+
|
|
21
|
+
const CONTENT = defaultDocsContentDir();
|
|
22
|
+
|
|
23
|
+
function mcpPost(
|
|
24
|
+
fetch: (request: Request) => Promise<Response>,
|
|
25
|
+
body: unknown,
|
|
26
|
+
headers: Record<string, string> = {},
|
|
27
|
+
): Promise<Response> {
|
|
28
|
+
return fetch(
|
|
29
|
+
new Request("http://127.0.0.1:6536/mcp", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
host: "127.0.0.1:6536",
|
|
33
|
+
"content-type": "application/json",
|
|
34
|
+
...headers,
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("docs MCP Host/Origin", () => {
|
|
42
|
+
test("rejects attacker Host with 403", async () => {
|
|
43
|
+
const mcp = await createDocsMcpServer({ contentDir: CONTENT });
|
|
44
|
+
const res = await mcp.fetch(
|
|
45
|
+
new Request("http://127.0.0.1:6536/health", {
|
|
46
|
+
headers: { host: "attacker.example" },
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(res.status).toBe(403);
|
|
50
|
+
expect(await res.text()).toContain("Host");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("rejects disallowed Origin with 403", async () => {
|
|
54
|
+
const mcp = await createDocsMcpServer({ contentDir: CONTENT });
|
|
55
|
+
const res = await mcp.fetch(
|
|
56
|
+
new Request("http://127.0.0.1:6536/health", {
|
|
57
|
+
headers: {
|
|
58
|
+
host: "127.0.0.1:6536",
|
|
59
|
+
origin: "https://evil.example",
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
expect(res.status).toBe(403);
|
|
64
|
+
expect(await res.text()).toContain("Origin");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("allows loopback Host on /health", async () => {
|
|
68
|
+
const mcp = await createDocsMcpServer({ contentDir: CONTENT });
|
|
69
|
+
const res = await mcp.fetch(
|
|
70
|
+
new Request("http://127.0.0.1:6536/health", {
|
|
71
|
+
headers: { host: "127.0.0.1:6536" },
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const json = (await res.json()) as { surface: string };
|
|
76
|
+
expect(json.surface).toBe("docs-mcp");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("app MCP Host/Origin (behavioral)", () => {
|
|
81
|
+
test("rejects attacker Host with 403 before auth", async () => {
|
|
82
|
+
const store = createSessionStore();
|
|
83
|
+
const mcp = createMcpServer({
|
|
84
|
+
sessions: store,
|
|
85
|
+
secret: "test-secret",
|
|
86
|
+
context: {
|
|
87
|
+
getManifest: () => null,
|
|
88
|
+
listRuns: async () => [],
|
|
89
|
+
},
|
|
90
|
+
hostname: "127.0.0.1",
|
|
91
|
+
});
|
|
92
|
+
const res = await mcp.fetch(
|
|
93
|
+
new Request("http://127.0.0.1:6535/health", {
|
|
94
|
+
headers: { host: "attacker.example" },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
expect(res.status).toBe(403);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("docs MCP tools", () => {
|
|
102
|
+
test("tools/list exposes only oke.docs.search and oke.docs.get", async () => {
|
|
103
|
+
const mcp = await createDocsMcpServer({ contentDir: CONTENT });
|
|
104
|
+
const res = await mcpPost(mcp.fetch, {
|
|
105
|
+
jsonrpc: "2.0",
|
|
106
|
+
id: 1,
|
|
107
|
+
method: "tools/list",
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
const json = (await res.json()) as {
|
|
111
|
+
result: { tools: Array<{ name: string }> };
|
|
112
|
+
};
|
|
113
|
+
const names = json.result.tools.map((t) => t.name).sort();
|
|
114
|
+
expect(names).toEqual(["oke.docs.get", "oke.docs.search"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("oke.docs.get returns body byte-identical to on-disk source", async () => {
|
|
118
|
+
const mcp = await createDocsMcpServer({ contentDir: CONTENT });
|
|
119
|
+
const slug = "get-started/introduction";
|
|
120
|
+
const res = await mcpPost(mcp.fetch, {
|
|
121
|
+
jsonrpc: "2.0",
|
|
122
|
+
id: 2,
|
|
123
|
+
method: "tools/call",
|
|
124
|
+
params: {
|
|
125
|
+
name: "oke.docs.get",
|
|
126
|
+
arguments: { slug },
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
const json = (await res.json()) as {
|
|
131
|
+
result: { structuredContent: unknown };
|
|
132
|
+
};
|
|
133
|
+
const envelope = json.result.structuredContent;
|
|
134
|
+
expect(isDataEnvelope(envelope)).toBe(true);
|
|
135
|
+
if (!isDataEnvelope(envelope)) return;
|
|
136
|
+
expect(envelope.provenance).toBe("docs");
|
|
137
|
+
const content = envelope.content as { body: string; slug: string };
|
|
138
|
+
expect(content.slug).toBe(slug);
|
|
139
|
+
|
|
140
|
+
const raw = await Bun.file(
|
|
141
|
+
join(CONTENT, "get-started", "introduction.mdx"),
|
|
142
|
+
).text();
|
|
143
|
+
expect(content.body).toBe(stripYamlFrontmatter(raw));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("oke.docs.search finds a known page", async () => {
|
|
147
|
+
const index = await loadDocsIndex(CONTENT);
|
|
148
|
+
const mcp = await createDocsMcpServer({ index });
|
|
149
|
+
const res = await mcpPost(mcp.fetch, {
|
|
150
|
+
jsonrpc: "2.0",
|
|
151
|
+
id: 3,
|
|
152
|
+
method: "tools/call",
|
|
153
|
+
params: {
|
|
154
|
+
name: "oke.docs.search",
|
|
155
|
+
arguments: { query: "eight elements" },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
const json = (await res.json()) as {
|
|
160
|
+
result: { structuredContent: unknown };
|
|
161
|
+
};
|
|
162
|
+
const envelope = json.result.structuredContent;
|
|
163
|
+
expect(isDataEnvelope(envelope)).toBe(true);
|
|
164
|
+
if (!isDataEnvelope(envelope)) return;
|
|
165
|
+
const content = envelope.content as {
|
|
166
|
+
hits: Array<{ slug: string; title: string }>;
|
|
167
|
+
};
|
|
168
|
+
expect(content.hits.length).toBeGreaterThan(0);
|
|
169
|
+
expect(
|
|
170
|
+
content.hits.some(
|
|
171
|
+
(h) =>
|
|
172
|
+
h.slug.includes("introduction") || h.slug.includes("elements"),
|
|
173
|
+
),
|
|
174
|
+
).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only docs MCP HTTP server on port 6536.
|
|
3
|
+
*
|
|
4
|
+
* Host/Origin validation uses the shared {@link checkRequestSecurity}
|
|
5
|
+
* validator (same rules as the app MCP on :6535). No Bearer auth — this
|
|
6
|
+
* surface serves public documentation, not a live Manifest.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
checkRequestSecurity,
|
|
11
|
+
forbiddenResponse,
|
|
12
|
+
resolveAllowedHosts,
|
|
13
|
+
} from "../runtime/security.ts";
|
|
14
|
+
import { DOCS_MCP_PORT, type ServerHandle } from "../runtime/types.ts";
|
|
15
|
+
import { asData } from "./data.ts";
|
|
16
|
+
import {
|
|
17
|
+
defaultDocsContentDir,
|
|
18
|
+
loadDocsIndex,
|
|
19
|
+
type DocsIndex,
|
|
20
|
+
} from "./docs-index.ts";
|
|
21
|
+
import { createDocsToolRuntime } from "./docs-tools.ts";
|
|
22
|
+
import {
|
|
23
|
+
MCP_PROTOCOL_VERSION,
|
|
24
|
+
parseJsonRpcRequest,
|
|
25
|
+
parseToolsCallParams,
|
|
26
|
+
rpcError,
|
|
27
|
+
rpcSuccess,
|
|
28
|
+
RpcErrorCode,
|
|
29
|
+
type JsonRpcId,
|
|
30
|
+
type McpInitializeResult,
|
|
31
|
+
} from "./protocol.ts";
|
|
32
|
+
import { newMcpTransportSessionId } from "./session.ts";
|
|
33
|
+
|
|
34
|
+
/** Options for {@link createDocsMcpServer} / {@link serveDocsMcp}. */
|
|
35
|
+
export interface CreateDocsMcpServerOptions {
|
|
36
|
+
/** Preloaded index (tests). When omitted, load from {@link contentDir}. */
|
|
37
|
+
readonly index?: DocsIndex;
|
|
38
|
+
/** Docs content directory. Defaults to `site/content/docs`. */
|
|
39
|
+
readonly contentDir?: string;
|
|
40
|
+
readonly version?: string;
|
|
41
|
+
readonly allowedHosts?: readonly string[];
|
|
42
|
+
readonly hostname?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** In-process docs MCP handler (no listen). */
|
|
46
|
+
export interface DocsMcpServer {
|
|
47
|
+
/** Handle one HTTP request. */
|
|
48
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
49
|
+
/** Bound docs index. */
|
|
50
|
+
readonly index: DocsIndex;
|
|
51
|
+
/** Tool runtime (tests). */
|
|
52
|
+
readonly tools: ReturnType<typeof createDocsToolRuntime>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create a docs MCP request handler without listening.
|
|
57
|
+
*
|
|
58
|
+
* @param options - Index / security options
|
|
59
|
+
*/
|
|
60
|
+
export async function createDocsMcpServer(
|
|
61
|
+
options: CreateDocsMcpServerOptions = {},
|
|
62
|
+
): Promise<DocsMcpServer> {
|
|
63
|
+
const index =
|
|
64
|
+
options.index ??
|
|
65
|
+
(await loadDocsIndex(options.contentDir ?? defaultDocsContentDir()));
|
|
66
|
+
const tools = createDocsToolRuntime(index);
|
|
67
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
68
|
+
const allowed = resolveAllowedHosts(hostname, options.allowedHosts);
|
|
69
|
+
const version = options.version ?? "0.0.19";
|
|
70
|
+
const transportSessions = new Set<string>();
|
|
71
|
+
|
|
72
|
+
const fetch = async (request: Request): Promise<Response> => {
|
|
73
|
+
const security = checkRequestSecurity(request, allowed);
|
|
74
|
+
if (!security.ok) return forbiddenResponse(security.reason);
|
|
75
|
+
|
|
76
|
+
const url = new URL(request.url);
|
|
77
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
78
|
+
return Response.json({ ok: true, surface: "docs-mcp" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (request.method !== "POST" || url.pathname !== "/mcp") {
|
|
82
|
+
return new Response("Not Found", { status: 404 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let body: unknown;
|
|
86
|
+
try {
|
|
87
|
+
body = await request.json();
|
|
88
|
+
} catch {
|
|
89
|
+
return jsonRpcHttp(
|
|
90
|
+
rpcError(null, RpcErrorCode.parse, "invalid JSON body"),
|
|
91
|
+
400,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parsed = parseJsonRpcRequest(body);
|
|
96
|
+
if (!parsed.ok) {
|
|
97
|
+
return jsonRpcHttp(
|
|
98
|
+
rpcError(null, RpcErrorCode.invalidRequest, parsed.message),
|
|
99
|
+
400,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { request: rpc } = parsed;
|
|
104
|
+
const id: JsonRpcId = rpc.id;
|
|
105
|
+
|
|
106
|
+
switch (rpc.method) {
|
|
107
|
+
case "initialize": {
|
|
108
|
+
const sessionId = newMcpTransportSessionId();
|
|
109
|
+
transportSessions.add(sessionId);
|
|
110
|
+
const result: McpInitializeResult = {
|
|
111
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
112
|
+
capabilities: { tools: { listChanged: false } },
|
|
113
|
+
serverInfo: { name: "okengine-docs-mcp", version },
|
|
114
|
+
sessionId,
|
|
115
|
+
};
|
|
116
|
+
return jsonRpcHttp(rpcSuccess(id, result));
|
|
117
|
+
}
|
|
118
|
+
case "ping":
|
|
119
|
+
return jsonRpcHttp(rpcSuccess(id, { ok: true }));
|
|
120
|
+
case "tools/list": {
|
|
121
|
+
const listed = tools.listTools().map((t) => ({
|
|
122
|
+
name: t.name,
|
|
123
|
+
description: t.description,
|
|
124
|
+
inputSchema: t.inputSchema,
|
|
125
|
+
annotations: {
|
|
126
|
+
readOnlyHint: true,
|
|
127
|
+
destructiveHint: false,
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
return jsonRpcHttp(
|
|
131
|
+
rpcSuccess(id, {
|
|
132
|
+
tools: listed,
|
|
133
|
+
_oke: asData({ count: listed.length }, "catalog"),
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
case "tools/call": {
|
|
138
|
+
const call = parseToolsCallParams(rpc.params);
|
|
139
|
+
if (!call.ok) {
|
|
140
|
+
return jsonRpcHttp(
|
|
141
|
+
rpcError(id, RpcErrorCode.invalidParams, call.message),
|
|
142
|
+
400,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const result = tools.callTool(call.name, call.arguments);
|
|
146
|
+
if (!result.ok) {
|
|
147
|
+
const code =
|
|
148
|
+
result.code === "not-found"
|
|
149
|
+
? RpcErrorCode.methodNotFound
|
|
150
|
+
: RpcErrorCode.invalidParams;
|
|
151
|
+
return jsonRpcHttp(
|
|
152
|
+
rpcError(id, code, result.message, result.data),
|
|
153
|
+
result.code === "not-found" ? 404 : 400,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return jsonRpcHttp(
|
|
157
|
+
rpcSuccess(id, {
|
|
158
|
+
content: [
|
|
159
|
+
{
|
|
160
|
+
type: "text",
|
|
161
|
+
text: JSON.stringify(result.data),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
structuredContent: result.data,
|
|
165
|
+
isError: false,
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
default:
|
|
170
|
+
return jsonRpcHttp(
|
|
171
|
+
rpcError(
|
|
172
|
+
id,
|
|
173
|
+
RpcErrorCode.methodNotFound,
|
|
174
|
+
`method not found: ${rpc.method}`,
|
|
175
|
+
),
|
|
176
|
+
404,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return { fetch, index, tools };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Options for {@link serveDocsMcp}. */
|
|
185
|
+
export interface ServeDocsMcpOptions extends CreateDocsMcpServerOptions {
|
|
186
|
+
readonly port?: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Running docs MCP server handle. */
|
|
190
|
+
export interface DocsMcpServerHandle extends ServerHandle {
|
|
191
|
+
readonly mcp: DocsMcpServer;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Listen on port 6536 (or override) with Host/Origin validation.
|
|
196
|
+
*
|
|
197
|
+
* @param options - Server options
|
|
198
|
+
*/
|
|
199
|
+
export async function serveDocsMcp(
|
|
200
|
+
options: ServeDocsMcpOptions = {},
|
|
201
|
+
): Promise<DocsMcpServerHandle> {
|
|
202
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
203
|
+
const port = options.port ?? DOCS_MCP_PORT;
|
|
204
|
+
const mcp = await createDocsMcpServer({ ...options, hostname });
|
|
205
|
+
const server = Bun.serve({
|
|
206
|
+
hostname,
|
|
207
|
+
port,
|
|
208
|
+
fetch: mcp.fetch,
|
|
209
|
+
});
|
|
210
|
+
const boundPort = server.port ?? port;
|
|
211
|
+
const boundHost = hostname;
|
|
212
|
+
const url = new URL(`http://${boundHost}:${boundPort}/`);
|
|
213
|
+
return {
|
|
214
|
+
mcp,
|
|
215
|
+
url,
|
|
216
|
+
port: boundPort,
|
|
217
|
+
hostname: boundHost,
|
|
218
|
+
fetch: mcp.fetch,
|
|
219
|
+
stop(closeActive = false) {
|
|
220
|
+
server.stop(closeActive);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function jsonRpcHttp(body: unknown, status = 200): Response {
|
|
226
|
+
return new Response(JSON.stringify(body), {
|
|
227
|
+
status,
|
|
228
|
+
headers: {
|
|
229
|
+
"content-type": "application/json; charset=utf-8",
|
|
230
|
+
"cache-control": "no-store",
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|