@skyphusion/search-mcp 0.1.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/LICENSE +661 -0
- package/README.md +144 -0
- package/package.json +75 -0
- package/public/ask-widget.css +77 -0
- package/public/ask-widget.js +165 -0
- package/scripts/config-paths.mjs +40 -0
- package/scripts/corpus-boundary.mjs +131 -0
- package/scripts/sync-ingest.mjs +141 -0
- package/scripts/sync-runner.mjs +213 -0
- package/scripts/sync.mjs +288 -0
- package/scripts/targets.json.example +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# search-mcp
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://github.com/skyphusion-labs/search-mcp/actions/workflows/typecheck.yml)
|
|
5
|
+
[](https://github.com/skyphusion-labs/search-mcp/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
Open-source toolkit for [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/):
|
|
8
|
+
|
|
9
|
+
1. **MCP Worker** (`src/mcp.ts`) -- bearer-gated Streamable-HTTP MCP with a `search` tool for agents.
|
|
10
|
+
2. **Query Worker** (`src/index.ts`) -- CORS + Turnstile + rate-limited `POST /ask` that streams answers for a browser widget.
|
|
11
|
+
3. **Corpus sync** (`scripts/sync.mjs`, `scripts/sync-runner.mjs`) -- git-tracked sources to R2, with extension remapping so TypeScript, Dockerfiles, and other text AI Search would otherwise skip get indexed.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
git repos -> sync.mjs -> R2 bucket -> AI Search instance -> /ask + /mcp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install (npm)
|
|
18
|
+
|
|
19
|
+
The corpus sync CLIs and ask-widget assets ship on npm as **`@skyphusion/search-mcp`** (the unscoped name `search-mcp` is taken by another project).
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @skyphusion/search-mcp
|
|
23
|
+
# or run without installing:
|
|
24
|
+
npx --package=@skyphusion/search-mcp search-mcp-sync corpus --dry-run
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Command | Role |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `search-mcp-sync` | Upload git-tracked corpus files to R2 for one target |
|
|
30
|
+
| `search-mcp-sync-run` | Clone/fetch repos, sync all targets, optional reindex |
|
|
31
|
+
|
|
32
|
+
Put `targets.json` in your project root (copy from `node_modules/@skyphusion/search-mcp/scripts/targets.json.example`) or set `SEARCH_MCP_TARGETS`. Clone roots default to the current working directory; override with `SYNC_REPO_ROOT`.
|
|
33
|
+
|
|
34
|
+
Widget assets after install:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
cp node_modules/@skyphusion/search-mcp/public/ask-widget.{js,css} ./docs/
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Workers (`src/`) deploy from a git clone; see [docs/DEPLOY.md](docs/DEPLOY.md).
|
|
41
|
+
|
|
42
|
+
## Quick start (from source)
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm install
|
|
46
|
+
cp wrangler.toml.example wrangler.toml
|
|
47
|
+
cp wrangler.mcp.toml.example wrangler.mcp.toml
|
|
48
|
+
cp scripts/targets.json.example scripts/targets.json
|
|
49
|
+
# edit the three files for your account, instance, bucket, and repos
|
|
50
|
+
|
|
51
|
+
npm run typecheck
|
|
52
|
+
npm test
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Provision R2 + AI Search, sync your corpus, deploy both Workers. Step-by-step: [docs/DEPLOY.md](docs/DEPLOY.md).
|
|
56
|
+
|
|
57
|
+
## Skyphusion production
|
|
58
|
+
|
|
59
|
+
This repo is the production home for Skyphusion AI Search (`search.vivijure.com`,
|
|
60
|
+
`search-internal.vivijure.com`). Config is materialized from GitHub Actions secrets at deploy/sync
|
|
61
|
+
time; do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json`.
|
|
62
|
+
|
|
63
|
+
- [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology, bootstrap
|
|
64
|
+
- [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history, failure log, archive steps
|
|
65
|
+
|
|
66
|
+
## Workers
|
|
67
|
+
|
|
68
|
+
| Worker | Entry | Endpoint | Auth |
|
|
69
|
+
| --- | --- | --- | --- |
|
|
70
|
+
| Query | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
|
|
71
|
+
| MCP | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
|
|
72
|
+
|
|
73
|
+
Deploy separately so browser traffic and agent traffic can bind different AI Search instances if you want.
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npm run deploy # query Worker
|
|
77
|
+
npm run deploy:mcp # MCP Worker
|
|
78
|
+
wrangler secret put MCP_TOKEN -c wrangler.mcp.toml
|
|
79
|
+
wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### MCP client wiring
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mcpServers": {
|
|
87
|
+
"search-mcp": {
|
|
88
|
+
"type": "http",
|
|
89
|
+
"url": "https://YOUR_MCP_HOST/mcp",
|
|
90
|
+
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`MCP_TOKEN` accepts a single token or comma-separated `name=token` pairs for per-consumer attribution in logs.
|
|
97
|
+
|
|
98
|
+
## Corpus sync
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
export R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... CLOUDFLARE_ACCOUNT_ID=...
|
|
102
|
+
export CORPUS_GIT_ORG=your-org GITHUB_TOKEN=... # for sync-runner clone auth
|
|
103
|
+
|
|
104
|
+
npm run sync:dry # plan upload for the default `corpus` target
|
|
105
|
+
npm run sync # upload + prune
|
|
106
|
+
npm run sync:run # isolated clone root, sync all targets, optional reindex
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The sync remaps non-native extensions (`.ts`, `.tsx`, extensionless `Dockerfile`, `.service`, etc.) to `.txt` keys so AI Search indexes them. See `scripts/sync-ingest.mjs`.
|
|
110
|
+
|
|
111
|
+
If you name a target `public`, optional boundary checks refuse overlap with `restrictedRepos` and can verify GitHub repo visibility before upload.
|
|
112
|
+
|
|
113
|
+
## Ask widget
|
|
114
|
+
|
|
115
|
+
Copy `public/ask-widget.js` and `public/ask-widget.css` to your docs site:
|
|
116
|
+
|
|
117
|
+
```html
|
|
118
|
+
<div id="docs-ask"></div>
|
|
119
|
+
<script defer src="/ask-widget.js"
|
|
120
|
+
data-endpoint="https://search.example.com/ask"
|
|
121
|
+
data-target="#docs-ask"
|
|
122
|
+
data-label="Ask the docs"
|
|
123
|
+
data-sitekey="YOUR_TURNSTILE_SITEKEY"></script>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Who this is for
|
|
127
|
+
|
|
128
|
+
Operators building documentation search, agent tooling, or internal knowledge bases on [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) with MCP and a browser widget.
|
|
129
|
+
|
|
130
|
+
## Links
|
|
131
|
+
|
|
132
|
+
- **Deploy guide:** [docs/DEPLOY.md](docs/DEPLOY.md)
|
|
133
|
+
- **Skyphusion Labs:** https://skyphusion.org · **Org:** https://github.com/skyphusion-labs
|
|
134
|
+
- **Related:** [vivijure](https://github.com/skyphusion-labs/vivijure), [postern](https://github.com/skyphusion-labs/postern)
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
AGPL-3.0-only. See [LICENSE](LICENSE).
|
|
139
|
+
|
|
140
|
+
## Community
|
|
141
|
+
|
|
142
|
+
- [Contributing](CONTRIBUTING.md)
|
|
143
|
+
- [Security policy](SECURITY.md)
|
|
144
|
+
- [Code of Conduct](CODE_OF_CONDUCT.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skyphusion/search-mcp",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Bearer-gated MCP server and streaming query Worker over Cloudflare AI Search, plus a git-to-R2 corpus sync toolkit.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"author": "Conrad Rockenhaus <conrad@skyphusion.org>",
|
|
7
|
+
"homepage": "https://github.com/skyphusion-labs/search-mcp#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/skyphusion-labs/search-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/skyphusion-labs/search-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cloudflare-workers",
|
|
17
|
+
"mcp",
|
|
18
|
+
"ai-search",
|
|
19
|
+
"rag",
|
|
20
|
+
"semantic-search",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"self-hosted",
|
|
23
|
+
"cloudflare",
|
|
24
|
+
"corpus-sync"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org/"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"scripts/corpus-boundary.mjs",
|
|
36
|
+
"scripts/config-paths.mjs",
|
|
37
|
+
"scripts/sync-ingest.mjs",
|
|
38
|
+
"scripts/sync.mjs",
|
|
39
|
+
"scripts/sync-runner.mjs",
|
|
40
|
+
"scripts/targets.json.example",
|
|
41
|
+
"public/ask-widget.js",
|
|
42
|
+
"public/ask-widget.css",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"bin": {
|
|
47
|
+
"search-mcp-sync": "./scripts/sync.mjs",
|
|
48
|
+
"search-mcp-sync-run": "./scripts/sync-runner.mjs"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"dev": "wrangler dev",
|
|
52
|
+
"dev:mcp": "wrangler dev -c wrangler.mcp.toml",
|
|
53
|
+
"deploy": "wrangler deploy",
|
|
54
|
+
"deploy:mcp": "wrangler deploy -c wrangler.mcp.toml",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"test": "vitest run",
|
|
57
|
+
"prepack": "npm run typecheck && npm test",
|
|
58
|
+
"sync": "node scripts/sync.mjs corpus",
|
|
59
|
+
"sync:public": "node scripts/sync.mjs public",
|
|
60
|
+
"sync:internal": "node scripts/sync.mjs internal",
|
|
61
|
+
"sync:dry": "node scripts/sync.mjs corpus --dry-run",
|
|
62
|
+
"sync:run": "node scripts/sync-runner.mjs",
|
|
63
|
+
"materialize-config": "node scripts/materialize-config.mjs"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@aws-sdk/client-s3": "^3.888.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@cloudflare/workers-types": "^4.20260606.1",
|
|
70
|
+
"@vitest/coverage-istanbul": "^4.1.10",
|
|
71
|
+
"typescript": "^6.0.3",
|
|
72
|
+
"vitest": "^4.1.10",
|
|
73
|
+
"wrangler": "^4.103.0"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* Embeddable ask widget. Neutral defaults; override any .vjask-* rule on the host site. */
|
|
2
|
+
.vjask {
|
|
3
|
+
--vjask-fg: #e8ecf4;
|
|
4
|
+
--vjask-muted: #93a0b5;
|
|
5
|
+
--vjask-bg: rgba(255, 255, 255, 0.03);
|
|
6
|
+
--vjask-border: rgba(255, 255, 255, 0.14);
|
|
7
|
+
--vjask-accent: #22d3ee;
|
|
8
|
+
--vjask-radius: 14px;
|
|
9
|
+
font-family: inherit;
|
|
10
|
+
color: var(--vjask-fg);
|
|
11
|
+
}
|
|
12
|
+
.vjask-form {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
gap: 0.75rem;
|
|
16
|
+
}
|
|
17
|
+
.vjask-label {
|
|
18
|
+
font-weight: 700;
|
|
19
|
+
font-size: 0.95rem;
|
|
20
|
+
}
|
|
21
|
+
.vjask-row {
|
|
22
|
+
display: flex;
|
|
23
|
+
gap: 0.5rem;
|
|
24
|
+
}
|
|
25
|
+
.vjask-input {
|
|
26
|
+
flex: 1;
|
|
27
|
+
min-width: 0;
|
|
28
|
+
padding: 0.7rem 0.9rem;
|
|
29
|
+
border-radius: var(--vjask-radius);
|
|
30
|
+
border: 1px solid var(--vjask-border);
|
|
31
|
+
background: var(--vjask-bg);
|
|
32
|
+
color: var(--vjask-fg);
|
|
33
|
+
font: inherit;
|
|
34
|
+
}
|
|
35
|
+
.vjask-input:focus {
|
|
36
|
+
outline: none;
|
|
37
|
+
border-color: var(--vjask-accent);
|
|
38
|
+
}
|
|
39
|
+
.vjask-btn {
|
|
40
|
+
padding: 0.7rem 1.2rem;
|
|
41
|
+
border-radius: var(--vjask-radius);
|
|
42
|
+
border: 1px solid transparent;
|
|
43
|
+
background: var(--vjask-accent);
|
|
44
|
+
color: #05121a;
|
|
45
|
+
font: inherit;
|
|
46
|
+
font-weight: 700;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
}
|
|
49
|
+
.vjask-btn:disabled {
|
|
50
|
+
opacity: 0.55;
|
|
51
|
+
cursor: progress;
|
|
52
|
+
}
|
|
53
|
+
.vjask-answer {
|
|
54
|
+
white-space: pre-wrap;
|
|
55
|
+
line-height: 1.7;
|
|
56
|
+
min-height: 1.5rem;
|
|
57
|
+
color: var(--vjask-fg);
|
|
58
|
+
}
|
|
59
|
+
.vjask-sources {
|
|
60
|
+
list-style: none;
|
|
61
|
+
margin: 0;
|
|
62
|
+
padding: 0.75rem 0 0;
|
|
63
|
+
border-top: 1px solid var(--vjask-border);
|
|
64
|
+
font-size: 0.85rem;
|
|
65
|
+
color: var(--vjask-muted);
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-wrap: wrap;
|
|
68
|
+
gap: 0.35rem 0.9rem;
|
|
69
|
+
}
|
|
70
|
+
.vjask-sources-title {
|
|
71
|
+
width: 100%;
|
|
72
|
+
font-weight: 700;
|
|
73
|
+
color: var(--vjask-fg);
|
|
74
|
+
}
|
|
75
|
+
.vjask-sources li {
|
|
76
|
+
font-family: ui-monospace, "JetBrains Mono", monospace;
|
|
77
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Embeddable ask widget. Vanilla JS, no build step. Streams answers from POST /ask.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// <div id="docs-ask"></div>
|
|
5
|
+
// <script defer src="/ask-widget.js"
|
|
6
|
+
// data-endpoint="https://search.example.com/ask"
|
|
7
|
+
// data-target="#docs-ask"
|
|
8
|
+
// data-label="Ask the docs"
|
|
9
|
+
// data-placeholder="How do I deploy?"
|
|
10
|
+
// data-sitekey="<TURNSTILE_SITEKEY>"></script>
|
|
11
|
+
(function () {
|
|
12
|
+
"use strict";
|
|
13
|
+
var script = document.currentScript;
|
|
14
|
+
var endpoint = script.getAttribute("data-endpoint") || "/ask";
|
|
15
|
+
var targetSel = script.getAttribute("data-target") || "#docs-ask";
|
|
16
|
+
var label = script.getAttribute("data-label") || "Ask";
|
|
17
|
+
var placeholder = script.getAttribute("data-placeholder") || "Ask a question about the docs";
|
|
18
|
+
var sitekey = script.getAttribute("data-sitekey") || "";
|
|
19
|
+
|
|
20
|
+
function ready(fn) {
|
|
21
|
+
if (document.readyState !== "loading") fn();
|
|
22
|
+
else document.addEventListener("DOMContentLoaded", fn);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ready(function () {
|
|
26
|
+
var root = document.querySelector(targetSel);
|
|
27
|
+
if (!root) return;
|
|
28
|
+
root.classList.add("vjask");
|
|
29
|
+
// Static template only. Label, placeholder, and Turnstile sitekey are applied via
|
|
30
|
+
// the DOM API below so attribute values are never concatenated into innerHTML.
|
|
31
|
+
root.innerHTML =
|
|
32
|
+
'<form class="vjask-form">' +
|
|
33
|
+
' <label class="vjask-label" for="vjask-input"></label>' +
|
|
34
|
+
' <div class="vjask-row">' +
|
|
35
|
+
' <input id="vjask-input" class="vjask-input" type="text" autocomplete="off" maxlength="2000" />' +
|
|
36
|
+
' <button class="vjask-btn" type="submit">Ask</button>' +
|
|
37
|
+
" </div>" +
|
|
38
|
+
(sitekey ? ' <div class="vjask-turnstile-slot"></div>' : "") +
|
|
39
|
+
' <div class="vjask-answer" aria-live="polite"></div>' +
|
|
40
|
+
' <ul class="vjask-sources" hidden></ul>' +
|
|
41
|
+
"</form>";
|
|
42
|
+
|
|
43
|
+
var form = root.querySelector(".vjask-form");
|
|
44
|
+
var input = root.querySelector(".vjask-input");
|
|
45
|
+
var btn = root.querySelector(".vjask-btn");
|
|
46
|
+
var answer = root.querySelector(".vjask-answer");
|
|
47
|
+
var sources = root.querySelector(".vjask-sources");
|
|
48
|
+
var labelEl = root.querySelector(".vjask-label");
|
|
49
|
+
if (labelEl) labelEl.textContent = label;
|
|
50
|
+
if (input) input.setAttribute("placeholder", placeholder);
|
|
51
|
+
|
|
52
|
+
if (sitekey) {
|
|
53
|
+
var slot = root.querySelector(".vjask-turnstile-slot");
|
|
54
|
+
if (slot) {
|
|
55
|
+
var ts = document.createElement("div");
|
|
56
|
+
ts.className = "vjask-turnstile cf-turnstile";
|
|
57
|
+
ts.setAttribute("data-sitekey", sitekey);
|
|
58
|
+
ts.setAttribute("data-size", "flexible");
|
|
59
|
+
slot.replaceWith(ts);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function turnstileToken() {
|
|
64
|
+
if (!sitekey || !window.turnstile) return "";
|
|
65
|
+
try {
|
|
66
|
+
return window.turnstile.getResponse() || "";
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderSources(chunks) {
|
|
73
|
+
var seen = {};
|
|
74
|
+
var items = [];
|
|
75
|
+
(chunks || []).forEach(function (c) {
|
|
76
|
+
var key = c && c.item && c.item.key;
|
|
77
|
+
if (key && !seen[key]) {
|
|
78
|
+
seen[key] = true;
|
|
79
|
+
items.push(key);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (!items.length) return;
|
|
83
|
+
sources.innerHTML = "<li class='vjask-sources-title'>Sources</li>" +
|
|
84
|
+
items.map(function (k) {
|
|
85
|
+
return "<li>" + k.replace(/[&<>]/g, function (ch) {
|
|
86
|
+
return { "&": "&", "<": "<", ">": ">" }[ch];
|
|
87
|
+
}) + "</li>";
|
|
88
|
+
}).join("");
|
|
89
|
+
sources.hidden = false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleEvent(block) {
|
|
93
|
+
var isChunks = /(^|\n)event:\s*chunks/.test(block);
|
|
94
|
+
var m = block.match(/(^|\n)data:\s?(.*)$/s);
|
|
95
|
+
if (!m) return;
|
|
96
|
+
var payload = m[2].trim();
|
|
97
|
+
if (payload === "[DONE]") return;
|
|
98
|
+
var data;
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(payload);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (isChunks) {
|
|
105
|
+
renderSources(data);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
var delta = data && data.choices && data.choices[0] && data.choices[0].delta;
|
|
109
|
+
if (delta && delta.content) answer.textContent += delta.content;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
form.addEventListener("submit", function (ev) {
|
|
113
|
+
ev.preventDefault();
|
|
114
|
+
var q = input.value.trim();
|
|
115
|
+
if (!q) return;
|
|
116
|
+
btn.disabled = true;
|
|
117
|
+
answer.textContent = "";
|
|
118
|
+
sources.hidden = true;
|
|
119
|
+
sources.innerHTML = "";
|
|
120
|
+
|
|
121
|
+
fetch(endpoint, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify({ question: q, turnstileToken: turnstileToken() }),
|
|
125
|
+
})
|
|
126
|
+
.then(function (res) {
|
|
127
|
+
if (!res.ok || !res.body) {
|
|
128
|
+
return res.json().then(
|
|
129
|
+
function (j) {
|
|
130
|
+
throw new Error(j.error || "error " + res.status);
|
|
131
|
+
},
|
|
132
|
+
function () {
|
|
133
|
+
throw new Error("error " + res.status);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
var reader = res.body.getReader();
|
|
138
|
+
var decoder = new TextDecoder();
|
|
139
|
+
var buf = "";
|
|
140
|
+
function pump() {
|
|
141
|
+
return reader.read().then(function (r) {
|
|
142
|
+
if (r.done) return;
|
|
143
|
+
buf += decoder.decode(r.value, { stream: true });
|
|
144
|
+
var parts = buf.split("\n\n");
|
|
145
|
+
buf = parts.pop();
|
|
146
|
+
parts.forEach(handleEvent);
|
|
147
|
+
return pump();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return pump();
|
|
151
|
+
})
|
|
152
|
+
.catch(function (err) {
|
|
153
|
+
answer.textContent = "Sorry, something went wrong (" + err.message + ").";
|
|
154
|
+
})
|
|
155
|
+
.finally(function () {
|
|
156
|
+
btn.disabled = false;
|
|
157
|
+
if (sitekey && window.turnstile) {
|
|
158
|
+
try {
|
|
159
|
+
window.turnstile.reset();
|
|
160
|
+
} catch (e) {}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
})();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Resolve targets.json for repo dev or npm-installed CLI usage. */
|
|
5
|
+
export function resolveTargetsPath(scriptsDir) {
|
|
6
|
+
if (process.env.SEARCH_MCP_TARGETS) {
|
|
7
|
+
return process.env.SEARCH_MCP_TARGETS;
|
|
8
|
+
}
|
|
9
|
+
const candidates = [
|
|
10
|
+
join(process.cwd(), "targets.json"),
|
|
11
|
+
join(scriptsDir, "targets.json"),
|
|
12
|
+
];
|
|
13
|
+
for (const path of candidates) {
|
|
14
|
+
if (existsSync(path)) return path;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Default git clone root for corpus sync. */
|
|
20
|
+
export function defaultRepoRoot(scriptsDir, targetsPath) {
|
|
21
|
+
if (process.env.SYNC_REPO_ROOT) {
|
|
22
|
+
return process.env.SYNC_REPO_ROOT;
|
|
23
|
+
}
|
|
24
|
+
if (!targetsPath) {
|
|
25
|
+
return join(scriptsDir, "..", "..");
|
|
26
|
+
}
|
|
27
|
+
const dir = dirname(targetsPath);
|
|
28
|
+
if (dir === scriptsDir) {
|
|
29
|
+
return join(scriptsDir, "..", "..");
|
|
30
|
+
}
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function targetsHelp(scriptsDir) {
|
|
35
|
+
const example = join(scriptsDir, "targets.json.example");
|
|
36
|
+
return [
|
|
37
|
+
"Missing targets.json.",
|
|
38
|
+
`Copy ${example} to ./targets.json (or set SEARCH_MCP_TARGETS).`,
|
|
39
|
+
].join(" ");
|
|
40
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Optional safety checks for scripts/sync.mjs when a target is named `public`.
|
|
2
|
+
//
|
|
3
|
+
// (1) targets.json overlap: assert targets.public.repos ∩ restrictedRepos === ∅.
|
|
4
|
+
// restrictedRepos only drives optional per-file metadata; it does NOT exclude a
|
|
5
|
+
// repo from the public bucket.
|
|
6
|
+
// (2) optional live GitHub visibility: refuse any non-public repo before upload.
|
|
7
|
+
|
|
8
|
+
/** Pure: repos listed in BOTH the public target and restrictedRepos. */
|
|
9
|
+
export function publicRestrictedOverlap(publicRepos, restrictedRepos) {
|
|
10
|
+
const restricted = new Set(restrictedRepos || []);
|
|
11
|
+
return (publicRepos || []).filter((r) => restricted.has(r));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Fail-closed when target is public and restrictedRepos overlaps the public list. */
|
|
15
|
+
export function assertPublicCorpusBoundary(cfg, targetName) {
|
|
16
|
+
if (targetName !== "public") return;
|
|
17
|
+
const overlap = publicRestrictedOverlap(
|
|
18
|
+
cfg?.targets?.public?.repos,
|
|
19
|
+
cfg?.restrictedRepos,
|
|
20
|
+
);
|
|
21
|
+
if (!overlap.length) return;
|
|
22
|
+
throw new Error(
|
|
23
|
+
"public corpus boundary violation: " +
|
|
24
|
+
overlap.join(", ") +
|
|
25
|
+
" listed in BOTH targets.public.repos and restrictedRepos. " +
|
|
26
|
+
"restrictedRepos only sets metadata; it does NOT exclude a repo from the public bucket. " +
|
|
27
|
+
"Remove each repo from one list before syncing public.",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function githubTokenFromEnv(env = process.env) {
|
|
32
|
+
return env.GITHUB_TOKEN || env.GH_TOKEN || "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// GitHub repo/org slug charset; rejects path segments and other URL metacharacters
|
|
36
|
+
// before repo names from targets.json reach the GitHub API fetch.
|
|
37
|
+
const GITHUB_SLUG_RE = /^[A-Za-z0-9._-]{1,100}$/;
|
|
38
|
+
|
|
39
|
+
export function assertGithubSlug(slug, kind) {
|
|
40
|
+
if (typeof slug !== "string" || !GITHUB_SLUG_RE.test(slug)) {
|
|
41
|
+
throw new Error(`invalid GitHub ${kind} for API request: ${String(slug)}`);
|
|
42
|
+
}
|
|
43
|
+
return slug;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const GITHUB_API_HEADERS = (token) => ({
|
|
47
|
+
Accept: "application/vnd.github+json",
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
50
|
+
"User-Agent": "search-mcp-corpus-boundary",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Paginated org repo list; URL uses only the org slug (env), never targets.json repo names. */
|
|
54
|
+
async function fetchPublicRepoNames(org, token, fetchImpl) {
|
|
55
|
+
const names = new Set();
|
|
56
|
+
let page = 1;
|
|
57
|
+
while (true) {
|
|
58
|
+
const url = new URL(`/orgs/${org}/repos`, "https://api.github.com");
|
|
59
|
+
url.searchParams.set("type", "public");
|
|
60
|
+
url.searchParams.set("per_page", "100");
|
|
61
|
+
url.searchParams.set("page", String(page));
|
|
62
|
+
const res = await fetchImpl(url.toString(), { headers: GITHUB_API_HEADERS(token) });
|
|
63
|
+
if (res.status === 404) {
|
|
64
|
+
throw new Error(`GitHub org not found: ${org}`);
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const snippet = (await res.text()).slice(0, 200);
|
|
68
|
+
throw new Error(`GitHub API ${res.status} listing public repos for ${org}: ${snippet}`);
|
|
69
|
+
}
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
if (!Array.isArray(body) || !body.length) break;
|
|
72
|
+
for (const entry of body) {
|
|
73
|
+
if (entry && typeof entry.name === "string") names.add(entry.name);
|
|
74
|
+
}
|
|
75
|
+
if (body.length < 100) break;
|
|
76
|
+
page += 1;
|
|
77
|
+
}
|
|
78
|
+
return names;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Live check: each repo must be visibility=public on GitHub. */
|
|
82
|
+
export async function verifyGithubRepoVisibility(repos, opts = {}) {
|
|
83
|
+
const {
|
|
84
|
+
org = "",
|
|
85
|
+
token = "",
|
|
86
|
+
fetchImpl = globalThis.fetch,
|
|
87
|
+
} = opts;
|
|
88
|
+
if (!org) {
|
|
89
|
+
throw new Error("org is required for GitHub visibility verification");
|
|
90
|
+
}
|
|
91
|
+
assertGithubSlug(org, "org");
|
|
92
|
+
if (!token) {
|
|
93
|
+
return { skipped: true, reason: "no_github_token", checked: [], nonPublic: [] };
|
|
94
|
+
}
|
|
95
|
+
if (!fetchImpl) {
|
|
96
|
+
throw new Error("fetch is not available for GitHub visibility verification");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const nonPublic = [];
|
|
100
|
+
const checked = [];
|
|
101
|
+
const publicNames = await fetchPublicRepoNames(org, token, fetchImpl);
|
|
102
|
+
for (const repo of repos || []) {
|
|
103
|
+
assertGithubSlug(repo, "repo");
|
|
104
|
+
checked.push(repo);
|
|
105
|
+
if (!publicNames.has(repo)) {
|
|
106
|
+
nonPublic.push({
|
|
107
|
+
repo,
|
|
108
|
+
visibility: "not_public_or_missing",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { skipped: false, checked, nonPublic };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Fail-closed when any public-target repo is not public on GitHub. Skips when no token. */
|
|
116
|
+
export async function assertPublicGithubVisibility(cfg, opts = {}) {
|
|
117
|
+
const repos = cfg?.targets?.public?.repos || [];
|
|
118
|
+
const org = opts.org ?? process.env.CORPUS_GIT_ORG ?? "";
|
|
119
|
+
const token = opts.token ?? githubTokenFromEnv(opts.env);
|
|
120
|
+
const result = await verifyGithubRepoVisibility(repos, { ...opts, org, token });
|
|
121
|
+
if (result.skipped) return result;
|
|
122
|
+
if (!result.nonPublic.length) return result;
|
|
123
|
+
const detail = result.nonPublic
|
|
124
|
+
.map((x) => `${x.repo} (${x.visibility}${x.reason ? ": " + x.reason : ""})`)
|
|
125
|
+
.join(", ");
|
|
126
|
+
throw new Error(
|
|
127
|
+
"public corpus boundary violation: non-public repos on GitHub would sync to the public bucket: " +
|
|
128
|
+
detail +
|
|
129
|
+
". Flip public on GitHub, remove from targets.public.repos, or sync under a non-public target.",
|
|
130
|
+
);
|
|
131
|
+
}
|