@semanticist14/clco 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/bun.lock +60 -4
- package/package.json +7 -2
- package/src/browsermcp.ts +22 -0
- package/src/claudehome.ts +33 -0
- package/src/cli.ts +22 -7
- package/src/config.ts +2 -0
- package/src/responses.ts +21 -10
- package/src/search.ts +131 -0
- package/src/server.ts +9 -1
- package/src/setup.ts +16 -9
- package/src/tokens.ts +1 -1
- package/src/translate.ts +14 -3
- package/src/webfetch.ts +60 -0
- package/src/webmcp.ts +45 -0
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ clco
|
|
|
61
61
|
|
|
62
62
|
1. **GitHub login**, once — a device-flow code to paste in your browser. The
|
|
63
63
|
token is stored at `~/.config/clco/auth.json` (mode 600).
|
|
64
|
-
2. **
|
|
64
|
+
2. **Four questions**, once. Re-run them any time with `clco setup`:
|
|
65
65
|
|
|
66
66
|
| Question | Default | What it does |
|
|
67
67
|
|---|---|---|
|
|
@@ -71,7 +71,7 @@ clco
|
|
|
71
71
|
|
|
72
72
|
3. **Pick a model**, then claude starts. Switch mid-session with `/model`.
|
|
73
73
|
|
|
74
|
-
Answering yes to the
|
|
74
|
+
Answering yes to the permission and browser questions together is worth understanding: claude runs
|
|
75
75
|
without permission prompts, and each session resolves the current
|
|
76
76
|
`@playwright/mcp` release from npm (`bunx -y @playwright/mcp@latest`) and runs
|
|
77
77
|
it with access to the browser tab you share. The version is re-resolved every
|
|
@@ -82,6 +82,14 @@ clco
|
|
|
82
82
|
`CLCO_MCP_PACKAGE` pins or redirects the package — see
|
|
83
83
|
[Browser control](#browser-control).
|
|
84
84
|
|
|
85
|
+
## Web access
|
|
86
|
+
|
|
87
|
+
Claude's built-in Fetch works as-is: it fetches pages locally and only the
|
|
88
|
+
summarizing model call rides the adapter. Claude's built-in WebSearch cannot
|
|
89
|
+
work here - Anthropic executes it server-side, and Copilot's endpoint rejects
|
|
90
|
+
a native web_search tool - so the adapter drops it instead of sending a
|
|
91
|
+
broken request. No web MCP server is registered.
|
|
92
|
+
|
|
85
93
|
## Usage
|
|
86
94
|
|
|
87
95
|
```sh
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## pi-web-access
|
|
4
|
+
|
|
5
|
+
The `web_fetch` implementation in `src/webfetch.ts` is an independent adaptation of the core public-URL fetching and readable-content extraction approach from [pi-web-access](https://github.com/nicobailon/pi-web-access), used under the MIT License.
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 nicobailon and contributors.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files, to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
package/bun.lock
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
"lockfileVersion":
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
3
|
"configVersion": 1,
|
|
4
4
|
"workspaces": {
|
|
5
5
|
"": {
|
|
6
|
-
"name": "
|
|
6
|
+
"name": "@semanticist14/clco",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@clack/prompts": "^1.8.0",
|
|
9
|
+
"@mozilla/readability": "^0.6.0",
|
|
10
|
+
"linkedom": "^0.18.12",
|
|
11
|
+
"turndown": "^7.0.3",
|
|
12
|
+
"unpdf": "^1.6.2",
|
|
9
13
|
},
|
|
10
14
|
"devDependencies": {
|
|
11
15
|
"@types/bun": "latest",
|
|
@@ -18,22 +22,74 @@
|
|
|
18
22
|
|
|
19
23
|
"@clack/prompts": ["@clack/prompts@1.8.0", "", { "dependencies": { "@clack/core": "1.5.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA=="],
|
|
20
24
|
|
|
25
|
+
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
|
26
|
+
|
|
27
|
+
"@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="],
|
|
28
|
+
|
|
21
29
|
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
|
22
30
|
|
|
23
|
-
"@types/node": ["@types/node@
|
|
31
|
+
"@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
|
|
32
|
+
|
|
33
|
+
"boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
|
|
24
34
|
|
|
25
35
|
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
|
26
36
|
|
|
37
|
+
"css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="],
|
|
38
|
+
|
|
39
|
+
"css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="],
|
|
40
|
+
|
|
41
|
+
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
|
|
42
|
+
|
|
43
|
+
"dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
|
|
44
|
+
|
|
45
|
+
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
|
46
|
+
|
|
47
|
+
"domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
|
48
|
+
|
|
49
|
+
"domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
|
|
50
|
+
|
|
51
|
+
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
|
52
|
+
|
|
27
53
|
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
|
28
54
|
|
|
29
55
|
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
|
30
56
|
|
|
31
57
|
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
|
32
58
|
|
|
59
|
+
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
|
60
|
+
|
|
61
|
+
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
|
62
|
+
|
|
63
|
+
"linkedom": ["linkedom@0.18.13", "", { "dependencies": { "css-select": "^7.0.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.1.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw=="],
|
|
64
|
+
|
|
65
|
+
"nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="],
|
|
66
|
+
|
|
33
67
|
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
|
34
68
|
|
|
69
|
+
"turndown": ["turndown@7.2.4", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
|
|
70
|
+
|
|
35
71
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
36
72
|
|
|
37
|
-
"
|
|
73
|
+
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
|
|
74
|
+
|
|
75
|
+
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
|
76
|
+
|
|
77
|
+
"unpdf": ["unpdf@1.8.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ=="],
|
|
78
|
+
|
|
79
|
+
"dom-serializer/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
|
80
|
+
|
|
81
|
+
"dom-serializer/entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
|
82
|
+
|
|
83
|
+
"domhandler/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
|
84
|
+
|
|
85
|
+
"domutils/domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
|
86
|
+
|
|
87
|
+
"htmlparser2/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
|
88
|
+
|
|
89
|
+
"htmlparser2/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
|
90
|
+
|
|
91
|
+
"htmlparser2/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
|
92
|
+
|
|
93
|
+
"htmlparser2/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
|
38
94
|
}
|
|
39
95
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semanticist14/clco",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Run Claude Code on a GitHub Copilot subscription",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"uninstall.sh",
|
|
19
19
|
"README.md",
|
|
20
20
|
"LICENSE",
|
|
21
|
+
"THIRD_PARTY_NOTICES.md",
|
|
21
22
|
"bun.lock"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
@@ -30,6 +31,10 @@
|
|
|
30
31
|
"typescript": "^5"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@clack/prompts": "^1.8.0"
|
|
34
|
+
"@clack/prompts": "^1.8.0",
|
|
35
|
+
"@mozilla/readability": "^0.6.0",
|
|
36
|
+
"unpdf": "^1.6.2",
|
|
37
|
+
"linkedom": "^0.18.12",
|
|
38
|
+
"turndown": "^7.0.3"
|
|
34
39
|
}
|
|
35
40
|
}
|
package/src/browsermcp.ts
CHANGED
|
@@ -192,6 +192,28 @@ export function browserMcpConfig(
|
|
|
192
192
|
})
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** Compose the session-only MCP config without replacing a user's config file. */
|
|
196
|
+
export function combinedMcpConfig(
|
|
197
|
+
browserInstalled: boolean,
|
|
198
|
+
webEnabled = false,
|
|
199
|
+
searchModel?: string,
|
|
200
|
+
): string | null {
|
|
201
|
+
const browser = browserMcpConfig(browserInstalled)
|
|
202
|
+
const mcpServers: Record<string, unknown> = browser
|
|
203
|
+
? ((JSON.parse(browser) as { mcpServers: Record<string, unknown> }).mcpServers)
|
|
204
|
+
: {}
|
|
205
|
+
if (webEnabled) {
|
|
206
|
+
const env: Record<string, string> = {}
|
|
207
|
+
if (searchModel) env.CLCO_SEARCH_AGENT = searchModel
|
|
208
|
+
mcpServers.clco_web = {
|
|
209
|
+
command: process.execPath,
|
|
210
|
+
args: [join(import.meta.dir, "webmcp.ts")],
|
|
211
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return Object.keys(mcpServers).length > 0 ? JSON.stringify({ mcpServers }) : null
|
|
215
|
+
}
|
|
216
|
+
|
|
195
217
|
// The extension mints a base64url value; nothing else should be accepted.
|
|
196
218
|
// A paste can easily pick up a shell prompt or a stray line, and storing that
|
|
197
219
|
// silently produces a token that never works and no clue why.
|
package/src/claudehome.ts
CHANGED
|
@@ -46,6 +46,35 @@ const PRIVATE_ENTRIES = new Set([
|
|
|
46
46
|
* user's file every launch discarded whatever claude had persisted in the
|
|
47
47
|
* private one.
|
|
48
48
|
*/
|
|
49
|
+
/**
|
|
50
|
+
* clco owns routing and auth: ANTHROPIC_BASE_URL and the bearer travel in
|
|
51
|
+
* the --settings tier. A provider env block in the user's own settings.json
|
|
52
|
+
* (e.g. z.ai's ANTHROPIC_AUTH_TOKEN) must not survive the copy: claude
|
|
53
|
+
* applies settings-file env over the child environment, so a surviving
|
|
54
|
+
* token would override clco's and every request would 401 against the
|
|
55
|
+
* adapter. ANTHROPIC_MODEL is stripped too: clco pins the session model
|
|
56
|
+
* itself, and a surviving pin makes every /model switch cosmetic.
|
|
57
|
+
*/
|
|
58
|
+
const ROUTING_ENV = /^ANTHROPIC_(AUTH_TOKEN|API_KEY|BASE_URL|MODEL)$/
|
|
59
|
+
function stripRoutingEnv(parsed: Record<string, unknown>): void {
|
|
60
|
+
const env = parsed.env as Record<string, unknown> | undefined
|
|
61
|
+
if (env && typeof env === "object" && !Array.isArray(env)) {
|
|
62
|
+
for (const key of Object.keys(env)) if (ROUTING_ENV.test(key)) delete env[key]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Claude's built-in WebSearch executes server-side on Anthropic's API and can
|
|
67
|
+
// never work through the adapter. Denying it in the private copy keeps the
|
|
68
|
+
// model from calling a tool that always no-ops and steering it toward the
|
|
69
|
+
// clco_web search tool instead. The user's own file is never touched.
|
|
70
|
+
function denyServerWebSearch(parsed: Record<string, unknown>): void {
|
|
71
|
+
const permissions = (parsed.permissions ?? {}) as Record<string, unknown>
|
|
72
|
+
const deny = Array.isArray(permissions.deny) ? [...permissions.deny] : []
|
|
73
|
+
if (!deny.includes("WebSearch")) deny.push("WebSearch")
|
|
74
|
+
permissions.deny = deny
|
|
75
|
+
parsed.permissions = permissions
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
async function writeSettings(real: string, home: string): Promise<void> {
|
|
50
79
|
const target = join(home, "settings.json")
|
|
51
80
|
const source = await readFile(join(real, "settings.json"), "utf8").catch(
|
|
@@ -57,6 +86,8 @@ async function writeSettings(real: string, home: string): Promise<void> {
|
|
|
57
86
|
try {
|
|
58
87
|
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
59
88
|
delete parsed.model
|
|
89
|
+
stripRoutingEnv(parsed)
|
|
90
|
+
denyServerWebSearch(parsed)
|
|
60
91
|
await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
|
|
61
92
|
} catch {
|
|
62
93
|
// claude tolerates comments where JSON.parse does not. A line-based regex
|
|
@@ -68,6 +99,8 @@ async function writeSettings(real: string, home: string): Promise<void> {
|
|
|
68
99
|
try {
|
|
69
100
|
const parsed = JSON.parse(stripJsonComments(raw)) as Record<string, unknown>
|
|
70
101
|
delete parsed.model
|
|
102
|
+
stripRoutingEnv(parsed)
|
|
103
|
+
denyServerWebSearch(parsed)
|
|
71
104
|
await atomicWrite(target, JSON.stringify(parsed, null, 2) + "\n")
|
|
72
105
|
} catch (err) {
|
|
73
106
|
// Neither form parses: keep the previous private file rather than
|
package/src/cli.ts
CHANGED
|
@@ -103,6 +103,13 @@ const pad = (rows: ReadonlyArray<[string, string]>, width: number) =>
|
|
|
103
103
|
|
|
104
104
|
export const COMMAND_LIST = `Commands:\n${pad(COMMANDS, 20)}`
|
|
105
105
|
|
|
106
|
+
function searchAgentModel(models: ReadonlyArray<{ id: string }>): string | undefined {
|
|
107
|
+
// Copilot's own search-agent models: a plans web queries, b/c plan code
|
|
108
|
+
// search. Prefer the web one; anything is better than no planner.
|
|
109
|
+
return models.find((model) => model.id === "copilot-search-a")?.id ??
|
|
110
|
+
models.find((model) => model.id.startsWith("copilot-search"))?.id
|
|
111
|
+
}
|
|
112
|
+
|
|
106
113
|
const HELP = `clco v${VERSION} — run Claude Code on your GitHub Copilot subscription
|
|
107
114
|
|
|
108
115
|
Usage:
|
|
@@ -160,7 +167,8 @@ export function parseArgs(rawArgv: string[]): Args {
|
|
|
160
167
|
if (
|
|
161
168
|
arg === "--no-bypass" ||
|
|
162
169
|
arg === "--no-select" ||
|
|
163
|
-
arg === "--no-browser"
|
|
170
|
+
arg === "--no-browser" ||
|
|
171
|
+
arg === "--no-web"
|
|
164
172
|
) {
|
|
165
173
|
overrides[arg.slice(5) as keyof SetupOverrides] = false
|
|
166
174
|
i++
|
|
@@ -836,15 +844,13 @@ async function main(): Promise<void> {
|
|
|
836
844
|
const browserEnabled =
|
|
837
845
|
setup?.browser === true && args.overrides.browser !== false
|
|
838
846
|
const browserExtension = browserEnabled ? await extensionInstalled() : false
|
|
847
|
+
// Web is on unless the user turned it off; absent prefs mean on.
|
|
848
|
+
const webEnabled = setup?.web !== false && args.overrides.web !== false
|
|
849
|
+
const searchAgent = searchAgentModel(list)
|
|
839
850
|
// Computed from the arguments actually produced: clco stands aside when the
|
|
840
851
|
// user passes their own --mcp-config, and claiming success there would be
|
|
841
852
|
// the same trap as registering a server with no extension.
|
|
842
|
-
const injected = setupClaudeArgs(
|
|
843
|
-
setup,
|
|
844
|
-
args.overrides,
|
|
845
|
-
args.claudeArgs,
|
|
846
|
-
browserExtension,
|
|
847
|
-
)
|
|
853
|
+
const injected = setupClaudeArgs(setup, args.overrides, args.claudeArgs, browserExtension, searchAgent)
|
|
848
854
|
const registered = injected.includes("--mcp-config")
|
|
849
855
|
const browserLine = startupLine(
|
|
850
856
|
browserEnabled,
|
|
@@ -860,6 +866,15 @@ async function main(): Promise<void> {
|
|
|
860
866
|
browserEnabled && browserExtension ? registered : undefined,
|
|
861
867
|
)
|
|
862
868
|
if (browserLine) console.error(browserLine)
|
|
869
|
+
console.error(
|
|
870
|
+
webEnabled && !searchAgent
|
|
871
|
+
? "+ web: unavailable (Copilot exposed no search-agent model)"
|
|
872
|
+
: webEnabled && injected.includes("--mcp-config")
|
|
873
|
+
? `+ web: search via ${searchAgent} planner + live local execution`
|
|
874
|
+
: webEnabled
|
|
875
|
+
? "+ web: enabled in setup, but not registered because --mcp-config was supplied"
|
|
876
|
+
: "+ web: disabled (run `clco setup` to enable)",
|
|
877
|
+
)
|
|
863
878
|
|
|
864
879
|
if (args.command === "serve") {
|
|
865
880
|
console.error("Adapter running... (Ctrl+C to stop)")
|
package/src/config.ts
CHANGED
package/src/responses.ts
CHANGED
|
@@ -141,6 +141,16 @@ export function toResponsesRequest(
|
|
|
141
141
|
|
|
142
142
|
const effort = effortFor(payload, allowedEfforts)
|
|
143
143
|
|
|
144
|
+
// Claude's built-in server-side tools (WebSearch, WebFetch) arrive without
|
|
145
|
+
// input_schema and cannot be executed by an upstream that only knows
|
|
146
|
+
// function tools - drop them rather than sending a broken schema. If
|
|
147
|
+
// tool_choice pinned one of the dropped tools, degrade to "auto", or the
|
|
148
|
+
// upstream rejects the request with "tool choice not found in tools".
|
|
149
|
+
const servable = (payload.tools ?? []).filter((t) => t.input_schema)
|
|
150
|
+
const choice = payload.tool_choice
|
|
151
|
+
const pinnedMissing =
|
|
152
|
+
choice?.type === "tool" && !servable.some((t) => t.name === choice.name)
|
|
153
|
+
|
|
144
154
|
return {
|
|
145
155
|
model: normalizeModel(payload.model),
|
|
146
156
|
...(instructions && { instructions }),
|
|
@@ -150,21 +160,22 @@ export function toResponsesRequest(
|
|
|
150
160
|
max_output_tokens: payload.max_tokens,
|
|
151
161
|
temperature: payload.temperature,
|
|
152
162
|
top_p: payload.top_p,
|
|
153
|
-
tools:
|
|
163
|
+
tools: servable.map((t) => ({
|
|
154
164
|
type: "function" as const,
|
|
155
165
|
name: t.name,
|
|
156
166
|
description: t.description,
|
|
157
167
|
parameters: t.input_schema,
|
|
158
168
|
})),
|
|
159
|
-
tool_choice:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
tool_choice: pinnedMissing
|
|
170
|
+
? ("auto" as const)
|
|
171
|
+
: choice?.type === "auto"
|
|
172
|
+
? ("auto" as const)
|
|
173
|
+
: choice?.type === "none"
|
|
174
|
+
? ("none" as const)
|
|
175
|
+
: choice?.type === "any"
|
|
176
|
+
? ("required" as const)
|
|
177
|
+
: choice?.name
|
|
178
|
+
? { type: "function" as const, name: choice.name }
|
|
168
179
|
: undefined,
|
|
169
180
|
}
|
|
170
181
|
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Semi-native web search: Copilot's dedicated search-agent model plans the
|
|
2
|
+
// queries (copilot-search-a; GitHub's own web-search planner, callable with
|
|
3
|
+
// the same OAuth token), execution is ours - DuckDuckGo HTML for results and
|
|
4
|
+
// the local fetcher for pages - and the planner synthesizes the final answer.
|
|
5
|
+
// Verified live 2026-09-13: the planner emits real read_article URLs only
|
|
6
|
+
// after live results are fed back, so information genuinely flows web ->
|
|
7
|
+
// model. Rounds are capped; any failure surfaces as WebFetchError.
|
|
8
|
+
import { getCopilotToken } from "./token"
|
|
9
|
+
import { copilotBaseUrl, copilotFetch, copilotRequestHeaders } from "./api"
|
|
10
|
+
import { fetchContent, WebFetchError } from "./webfetch"
|
|
11
|
+
|
|
12
|
+
export const SEARCH_AGENT = process.env.CLCO_SEARCH_AGENT ?? "copilot-search-a"
|
|
13
|
+
const MAX_ROUNDS = 4
|
|
14
|
+
const MAX_CALLS_PER_ROUND = 3
|
|
15
|
+
const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
|
|
16
|
+
|
|
17
|
+
type ToolCall = { id: string; function: { name: string; arguments: string } }
|
|
18
|
+
type Msg = Record<string, unknown>
|
|
19
|
+
|
|
20
|
+
async function chat(token: string, messages: Msg[], withTools = true): Promise<{ content: string; calls: ToolCall[] }> {
|
|
21
|
+
const res = await copilotFetch(`${copilotBaseUrl()}/chat/completions`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: copilotRequestHeaders(token, { accept: "application/json" }),
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
model: SEARCH_AGENT,
|
|
26
|
+
messages,
|
|
27
|
+
...(withTools
|
|
28
|
+
? {
|
|
29
|
+
tools: [
|
|
30
|
+
{ type: "web_search" },
|
|
31
|
+
{ type: "function", function: { name: "read_article", description: "Read a web page by URL.", parameters: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } } },
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
: {}),
|
|
35
|
+
stream: false,
|
|
36
|
+
}),
|
|
37
|
+
signal: AbortSignal.timeout(60_000),
|
|
38
|
+
})
|
|
39
|
+
if (!res.ok) throw new WebFetchError(`search agent failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`)
|
|
40
|
+
const json = (await res.json()) as { choices?: Array<{ message?: { content?: string; tool_calls?: ToolCall[] } }> }
|
|
41
|
+
const message = json.choices?.[0]?.message ?? {}
|
|
42
|
+
return { content: message.content ?? "", calls: message.tool_calls ?? [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function ddg(query: string): Promise<string> {
|
|
46
|
+
const res = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
|
|
47
|
+
headers: { "User-Agent": UA },
|
|
48
|
+
signal: AbortSignal.timeout(20_000),
|
|
49
|
+
})
|
|
50
|
+
const html = await res.text()
|
|
51
|
+
const results = [...html.matchAll(/class="result__a"[^>]*>([^<]+)<\/a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)]
|
|
52
|
+
.slice(0, 5)
|
|
53
|
+
.map((m, i) => `${i + 1}. ${strip(m[1]!)}: ${strip(m[2]!).slice(0, 250)}`)
|
|
54
|
+
return results.join("\n") || "(no results)"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function strip(html: string): string {
|
|
58
|
+
return html.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/'/g, "'").replace(/"/g, '"').trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readUrl(url: string): Promise<string> {
|
|
62
|
+
const { text } = await fetchContent(url, 4_000)
|
|
63
|
+
return text
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type LoopDeps = {
|
|
67
|
+
/** One planner turn: returns its text plus any tool calls it wants. */
|
|
68
|
+
ask: (messages: Msg[]) => Promise<{ content: string; calls: ToolCall[] }>
|
|
69
|
+
/** Execute a search query. */
|
|
70
|
+
search: (query: string) => Promise<string>
|
|
71
|
+
/** Read a page by URL. */
|
|
72
|
+
read: (url: string) => Promise<string>
|
|
73
|
+
/** Final answer from gathered material; must not plan more tool calls. */
|
|
74
|
+
synthesize: (messages: Msg[]) => Promise<string>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The planner/executor loop, injectable for tests. */
|
|
78
|
+
export async function searchLoop(query: string, deps: LoopDeps): Promise<string> {
|
|
79
|
+
const messages: Msg[] = [
|
|
80
|
+
{
|
|
81
|
+
role: "system",
|
|
82
|
+
content:
|
|
83
|
+
"You are a web research assistant. Always call web_search FIRST to find live results; never guess or construct URLs yourself - only read_article URLs that appeared in web_search results. Finish with a concise answer and list the sources you used.",
|
|
84
|
+
},
|
|
85
|
+
{ role: "user", content: query },
|
|
86
|
+
]
|
|
87
|
+
const done = new Map<string, string>()
|
|
88
|
+
let material = ""
|
|
89
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
90
|
+
const { content, calls } = await deps.ask(messages)
|
|
91
|
+
if (!calls.length) return content.trim() || material || "(no results)"
|
|
92
|
+
messages.push({ role: "assistant", content: content || null, tool_calls: calls })
|
|
93
|
+
for (const call of calls.slice(0, MAX_CALLS_PER_ROUND)) {
|
|
94
|
+
let args: { query?: string; url?: string } = {}
|
|
95
|
+
try { args = JSON.parse(call.function.arguments || "{}") } catch { /* empty args stay empty */ }
|
|
96
|
+
// Re-running a failed or finished call only burns rounds; replay it.
|
|
97
|
+
const key = `${call.function.name}:${args.query ?? args.url ?? ""}`
|
|
98
|
+
let output = done.get(key)
|
|
99
|
+
if (output === undefined) {
|
|
100
|
+
if (call.function.name === "read_article" && args.url) {
|
|
101
|
+
output = await deps.read(args.url).catch((error) => `(fetch failed: ${(error as Error).message}. Use web_search to find a working source.)`)
|
|
102
|
+
material += `\n(source) ${args.url}\n${output.slice(0, 300)}`
|
|
103
|
+
} else {
|
|
104
|
+
output = await deps.search(args.query ?? query)
|
|
105
|
+
material += `\n${output}`
|
|
106
|
+
}
|
|
107
|
+
done.set(key, output)
|
|
108
|
+
}
|
|
109
|
+
messages.push({ role: "tool", tool_call_id: call.id, content: output })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// The planner always wants one more round, so conclude without tools:
|
|
113
|
+
// answer the original question from the gathered material only.
|
|
114
|
+
return (
|
|
115
|
+
await deps.synthesize([
|
|
116
|
+
...messages,
|
|
117
|
+
{ role: "user", content: "Stop searching. Answer the original question using only the results gathered above. Be concise and list the sources you used." },
|
|
118
|
+
])
|
|
119
|
+
).trim() || material.trim()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One search: plan with the agent, execute, feed back, return the answer. */
|
|
123
|
+
export async function runSearch(query: string): Promise<string> {
|
|
124
|
+
const token = await getCopilotToken()
|
|
125
|
+
return searchLoop(query, {
|
|
126
|
+
ask: (messages) => chat(token, messages),
|
|
127
|
+
search: ddg,
|
|
128
|
+
read: readUrl,
|
|
129
|
+
synthesize: async (messages) => (await chat(token, messages, false)).content,
|
|
130
|
+
})
|
|
131
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -540,9 +540,14 @@ async function handleMessages(
|
|
|
540
540
|
// origin we actually bound instead.
|
|
541
541
|
function authorize(req: Request, selfHost: string): Response | null {
|
|
542
542
|
if (req.headers.get("host") !== selfHost) {
|
|
543
|
+
logLine(`[${timestamp()}] -> 403 ${new URL(req.url).pathname} host=${req.headers.get("host")}`)
|
|
543
544
|
return anthropicError(403, "host header mismatch")
|
|
544
545
|
}
|
|
545
|
-
|
|
546
|
+
const auth = req.headers.get("authorization")
|
|
547
|
+
if (auth !== `Bearer ${LOCAL_TOKEN}`) {
|
|
548
|
+
// Never log the full value: enough to tell which credential source sent it.
|
|
549
|
+
const got = auth === null ? "(none)" : auth.startsWith("Bearer ") ? `Bearer ${auth.slice(7, 15)}…(${auth.length - 7} chars)` : `other(${auth.slice(0, 8)}…)`
|
|
550
|
+
logLine(`[${timestamp()}] -> 401 ${new URL(req.url).pathname} auth=${got} expected=Bearer ${LOCAL_TOKEN.slice(0, 8)}…`)
|
|
546
551
|
return anthropicError(401, "missing or invalid adapter token")
|
|
547
552
|
}
|
|
548
553
|
return null
|
|
@@ -624,6 +629,9 @@ export async function startServer(
|
|
|
624
629
|
const server = Bun.serve({
|
|
625
630
|
hostname: "127.0.0.1",
|
|
626
631
|
port: opts.port ?? 0,
|
|
632
|
+
// Search-planner and provider calls can hold the client socket silent
|
|
633
|
+
// for the whole upstream generation; Bun's 10s default killed those.
|
|
634
|
+
idleTimeout: 255,
|
|
627
635
|
fetch: (req) =>
|
|
628
636
|
handle(req, upstreamBase, mockToken, selfHost).catch((err) =>
|
|
629
637
|
anthropicError(500, String(err)),
|
package/src/setup.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as p from "@clack/prompts"
|
|
6
6
|
import {
|
|
7
7
|
TOKEN_ENV,
|
|
8
|
-
|
|
8
|
+
combinedMcpConfig,
|
|
9
9
|
extensionInstalled,
|
|
10
10
|
parseToken,
|
|
11
11
|
setupNote,
|
|
@@ -13,12 +13,13 @@ import {
|
|
|
13
13
|
import { loadPrefs, savePrefs, type SetupPrefs } from "./config"
|
|
14
14
|
|
|
15
15
|
/** Bump when an option is added, so existing users get told once. */
|
|
16
|
-
export const SETUP_VERSION =
|
|
16
|
+
export const SETUP_VERSION = 3
|
|
17
17
|
|
|
18
18
|
export interface SetupOverrides {
|
|
19
19
|
bypass?: boolean
|
|
20
20
|
select?: boolean
|
|
21
21
|
browser?: boolean
|
|
22
|
+
web?: boolean
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
// What the first-run prompts come pre-filled with: these are the options
|
|
@@ -30,6 +31,7 @@ export const SETUP_DEFAULTS: Omit<SetupPrefs, "version"> = {
|
|
|
30
31
|
bypass: true,
|
|
31
32
|
select: true,
|
|
32
33
|
browser: true,
|
|
34
|
+
web: true,
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export async function runSetup(): Promise<SetupPrefs> {
|
|
@@ -62,6 +64,11 @@ export async function runSetup(): Promise<SetupPrefs> {
|
|
|
62
64
|
current.bypass,
|
|
63
65
|
)
|
|
64
66
|
|
|
67
|
+
setup.web = await ask(
|
|
68
|
+
"Search the web via Copilot's search planner (queries by copilot-search-a, live execution locally)?",
|
|
69
|
+
current.web ?? true,
|
|
70
|
+
)
|
|
71
|
+
|
|
65
72
|
// Claude's own Chrome integration cannot work here, so there is nothing to
|
|
66
73
|
// ask about it - only an alternative to offer.
|
|
67
74
|
setup.browser = await ask(
|
|
@@ -98,7 +105,7 @@ export async function runSetup(): Promise<SetupPrefs> {
|
|
|
98
105
|
await savePrefs({ ...prefs, setup })
|
|
99
106
|
p.outro(
|
|
100
107
|
"Saved. Re-run `clco setup` to change it, or turn one off for a single\n" +
|
|
101
|
-
"run with --no-bypass / --no-select / --no-browser.",
|
|
108
|
+
"run with --no-bypass / --no-select / --no-browser / --no-web.",
|
|
102
109
|
)
|
|
103
110
|
return setup
|
|
104
111
|
}
|
|
@@ -135,6 +142,8 @@ export function setupClaudeArgs(
|
|
|
135
142
|
claudeArgs: string[],
|
|
136
143
|
/** Whether the Playwright MCP Bridge extension is installed. */
|
|
137
144
|
browserExtension = false,
|
|
145
|
+
/** The Copilot search-agent model the web tool plans with. */
|
|
146
|
+
searchAgent?: string,
|
|
138
147
|
): string[] {
|
|
139
148
|
if (!setup) return []
|
|
140
149
|
// Match the `--flag=value` form too: an exact comparison let a user's own
|
|
@@ -153,12 +162,10 @@ export function setupClaudeArgs(
|
|
|
153
162
|
}
|
|
154
163
|
// Registered per session rather than written into the user's MCP config, so
|
|
155
164
|
// clco never edits configuration that outlives it.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
) {
|
|
161
|
-
const config = browserMcpConfig(browserExtension)
|
|
165
|
+
const browserRequested = setup.browser && overrides.browser !== false && browserExtension
|
|
166
|
+
const webRequested = setup.web === true && overrides.web !== false
|
|
167
|
+
if ((browserRequested || webRequested) && !has("--mcp-config")) {
|
|
168
|
+
const config = combinedMcpConfig(browserExtension, webRequested, searchAgent)
|
|
162
169
|
if (config) out.push("--mcp-config", config)
|
|
163
170
|
}
|
|
164
171
|
return out
|
package/src/tokens.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function estimateTokens(payload: AnthropicRequest): number {
|
|
|
48
48
|
count(payload.system)
|
|
49
49
|
for (const tool of payload.tools ?? []) {
|
|
50
50
|
chars += tool.name.length + (tool.description?.length ?? 0)
|
|
51
|
-
chars += JSON.stringify(tool.input_schema).length
|
|
51
|
+
chars += JSON.stringify(tool.input_schema ?? {}).length
|
|
52
52
|
}
|
|
53
53
|
return Math.ceil(chars / CHARS_PER_TOKEN) + images * IMAGE_TOKENS +
|
|
54
54
|
(payload.messages?.length ?? 0) * MESSAGE_TOKENS
|
package/src/translate.ts
CHANGED
|
@@ -249,6 +249,7 @@ function translateTools(
|
|
|
249
249
|
|
|
250
250
|
function translateToolChoice(
|
|
251
251
|
choice: AnthropicRequest["tool_choice"],
|
|
252
|
+
servableNames: Set<string>,
|
|
252
253
|
): OpenAIRequest["tool_choice"] {
|
|
253
254
|
if (!choice) return undefined
|
|
254
255
|
switch (choice.type) {
|
|
@@ -259,9 +260,12 @@ function translateToolChoice(
|
|
|
259
260
|
case "none":
|
|
260
261
|
return "none"
|
|
261
262
|
case "tool":
|
|
262
|
-
|
|
263
|
+
// A pinned tool that was dropped (Claude's built-in server-side ones
|
|
264
|
+
// carry no input_schema) must degrade to "auto", or the upstream
|
|
265
|
+
// rejects with "tool choice not found in tools".
|
|
266
|
+
return choice.name && servableNames.has(choice.name)
|
|
263
267
|
? { type: "function", function: { name: choice.name } }
|
|
264
|
-
:
|
|
268
|
+
: "auto"
|
|
265
269
|
default:
|
|
266
270
|
return undefined
|
|
267
271
|
}
|
|
@@ -298,7 +302,14 @@ export function translateRequest(
|
|
|
298
302
|
top_p: payload.top_p,
|
|
299
303
|
user: payload.metadata?.user_id ?? null,
|
|
300
304
|
tools: translateTools(payload.tools),
|
|
301
|
-
tool_choice: translateToolChoice(
|
|
305
|
+
tool_choice: translateToolChoice(
|
|
306
|
+
payload.tool_choice,
|
|
307
|
+
new Set(
|
|
308
|
+
(payload.tools ?? [])
|
|
309
|
+
.filter((tool) => tool.input_schema)
|
|
310
|
+
.map((tool) => tool.name),
|
|
311
|
+
),
|
|
312
|
+
),
|
|
302
313
|
}
|
|
303
314
|
}
|
|
304
315
|
|
package/src/webfetch.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises"
|
|
2
|
+
import { Readability } from "@mozilla/readability"
|
|
3
|
+
import { parseHTML } from "linkedom"
|
|
4
|
+
import TurndownService from "turndown"
|
|
5
|
+
|
|
6
|
+
const MAX_BYTES = 5 * 1024 * 1024
|
|
7
|
+
const MAX_REDIRECTS = 5
|
|
8
|
+
const FETCH_TIMEOUT_MS = 20_000
|
|
9
|
+
export class WebFetchError extends Error {}
|
|
10
|
+
|
|
11
|
+
export function privateAddress(address: string): boolean {
|
|
12
|
+
const value = address.toLowerCase()
|
|
13
|
+
if (value.startsWith("::ffff:")) return privateAddress(value.slice(7))
|
|
14
|
+
if (value === "::" || value === "::1" || value.startsWith("fc") || value.startsWith("fd") || value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb") || value.startsWith("ff") || value.startsWith("2001:db8:")) return true
|
|
15
|
+
const octets = value.split(".").map(Number)
|
|
16
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false
|
|
17
|
+
const [a, b, c] = octets
|
|
18
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) || (a === 203 && b === 0 && c === 113)
|
|
19
|
+
}
|
|
20
|
+
async function publicAddress(url: URL): Promise<string> {
|
|
21
|
+
const addresses = await Promise.race([lookup(url.hostname, { all: true, verbatim: true }), new Promise<never>((_, reject) => setTimeout(() => reject(new WebFetchError("web_fetch DNS lookup timed out")), 5_000))])
|
|
22
|
+
if (!addresses.length || addresses.some(({ address }) => privateAddress(address))) throw new WebFetchError("web_fetch blocked a private or local destination")
|
|
23
|
+
return addresses[0]!.address
|
|
24
|
+
}
|
|
25
|
+
async function validateUrl(raw: string): Promise<URL> {
|
|
26
|
+
let url: URL
|
|
27
|
+
try { url = new URL(raw) } catch { throw new WebFetchError("web_fetch requires a valid URL") }
|
|
28
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new WebFetchError("web_fetch only permits credential-free HTTP(S) URLs")
|
|
29
|
+
await publicAddress(url)
|
|
30
|
+
return url
|
|
31
|
+
}
|
|
32
|
+
async function pinnedFetch(url: URL, init: RequestInit): Promise<Response> {
|
|
33
|
+
const address = await publicAddress(url); const target = new URL(url); const originalHost = target.host; target.hostname = address; const headers = new Headers(init.headers); headers.set("host", originalHost); const options = { ...init, headers } as RequestInit & { tls?: { serverName: string } }; if (url.protocol === "https:") options.tls = { serverName: url.hostname }; return fetch(target, options as RequestInit)
|
|
34
|
+
}
|
|
35
|
+
async function readLimited(response: Response): Promise<Uint8Array> {
|
|
36
|
+
if (!response.body) throw new WebFetchError("web_fetch received an empty response")
|
|
37
|
+
const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0
|
|
38
|
+
try { while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_BYTES) throw new WebFetchError("web_fetch refused a response larger than 5 MB"); chunks.push(value) } } finally { await reader.cancel().catch(() => {}) }
|
|
39
|
+
const result = new Uint8Array(total); let offset = 0
|
|
40
|
+
for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.byteLength }
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
async function pdfText(bytes: Uint8Array): Promise<string> {
|
|
44
|
+
try { const module = await import("unpdf") as { extractText?: (data: Uint8Array) => Promise<unknown> }; if (!module.extractText) throw new Error(); const result = await module.extractText(bytes); if (typeof result === "string") return result; const text = (result as { text?: string | string[] } | null)?.text; return Array.isArray(text) ? text.join("\n") : text ?? "" } catch { throw new WebFetchError("web_fetch could not extract text from this PDF") }
|
|
45
|
+
}
|
|
46
|
+
export async function fetchContent(rawUrl: string, maxChars = 30_000): Promise<{ url: string; contentType: string; text: string; title?: string }> {
|
|
47
|
+
let url = await validateUrl(rawUrl); let response: Response | undefined
|
|
48
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) { response = await pinnedFetch(url, { redirect: "manual", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (![301, 302, 303, 307, 308].includes(response.status)) break; const location = response.headers.get("location"); if (!location || redirects === MAX_REDIRECTS) throw new WebFetchError("web_fetch followed too many redirects"); url = await validateUrl(new URL(location, url).toString()) }
|
|
49
|
+
if (!response) throw new WebFetchError("web_fetch did not receive a response"); if (!response.ok) throw new WebFetchError(`web_fetch failed: HTTP ${response.status}`)
|
|
50
|
+
const bytes = await readLimited(response); const contentType = (response.headers.get("content-type") ?? "application/octet-stream").split(";", 1)[0].trim().toLowerCase()
|
|
51
|
+
if (contentType === "application/pdf" || url.pathname.toLowerCase().endsWith(".pdf")) return { url: url.toString(), contentType, text: (await pdfText(bytes)).trim().slice(0, maxChars) }
|
|
52
|
+
const source = new TextDecoder().decode(bytes)
|
|
53
|
+
if (["application/json", "text/plain", "text/csv", "application/xml"].includes(contentType)) return { url: url.toString(), contentType, text: source.slice(0, maxChars) }
|
|
54
|
+
if (!contentType.includes("html")) throw new WebFetchError(`web_fetch does not support ${contentType || "this content type"}`)
|
|
55
|
+
const { document } = parseHTML(source); const article = new Readability(document as unknown as Document).parse()
|
|
56
|
+
if (!article?.content) throw new WebFetchError("web_fetch found no readable static content; use browser control for JavaScript-only pages")
|
|
57
|
+
const markdown = new TurndownService({ codeBlockStyle: "fenced" }).turndown(article.content).trim()
|
|
58
|
+
if (!markdown) throw new WebFetchError("web_fetch found no readable static content; use browser control for JavaScript-only pages")
|
|
59
|
+
return { url: url.toString(), contentType, title: article.title || undefined, text: markdown.slice(0, maxChars) }
|
|
60
|
+
}
|
package/src/webmcp.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Session-scoped MCP server exposing one tool: web_search. The heavy lifting
|
|
2
|
+
// lives in search.ts (planner + execution loop); this file is the stdio
|
|
3
|
+
// JSON-RPC wrapper Claude Code talks to.
|
|
4
|
+
import { runSearch, SEARCH_AGENT } from "./search"
|
|
5
|
+
import { WebFetchError } from "./webfetch"
|
|
6
|
+
import { loadAuth } from "./config"
|
|
7
|
+
|
|
8
|
+
type Rpc = { jsonrpc: "2.0"; id?: string | number; method: string; params?: Record<string, unknown> }
|
|
9
|
+
function reply(id: Rpc["id"], result: unknown): void { if (id !== undefined) process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n") }
|
|
10
|
+
function failure(message: string): { content: [{ type: "text"; text: string }]; isError: true } { return { content: [{ type: "text", text: message }], isError: true } }
|
|
11
|
+
|
|
12
|
+
const tools = [
|
|
13
|
+
{ name: "web_search", description: "Search the live web. Use this whenever the user asks to search, look up, or find current information - results are fresh, not from training data.", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
async function call(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
17
|
+
try {
|
|
18
|
+
if (name === "web_search") {
|
|
19
|
+
if (!(await loadAuth())?.github_token && !process.env.CLCO_UPSTREAM) return failure("GitHub authentication is required; run `clco login` before using web search")
|
|
20
|
+
return { content: [{ type: "text", text: await runSearch(String(args.query ?? "")) }] }
|
|
21
|
+
}
|
|
22
|
+
return failure(`unknown web tool: ${name}`)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return failure(error instanceof Error ? error.message : String(error))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
process.stdin.setEncoding("utf8")
|
|
29
|
+
let input = ""
|
|
30
|
+
let queue = Promise.resolve()
|
|
31
|
+
process.stdin.on("data", (chunk: string) => {
|
|
32
|
+
input += chunk
|
|
33
|
+
for (const line of input.split("\n").slice(0, -1)) queue = queue.then(() => handle(line))
|
|
34
|
+
input = input.split("\n").pop() ?? ""
|
|
35
|
+
})
|
|
36
|
+
async function handle(line: string): Promise<void> {
|
|
37
|
+
if (!line.trim()) return
|
|
38
|
+
let message: Rpc
|
|
39
|
+
try { message = JSON.parse(line) as Rpc } catch { return }
|
|
40
|
+
if (message.method === "initialize") return reply(message.id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clco-web", version: "0.1.0" }, instructions: "This server provides live web search. Whenever the user asks to search the web, look something up online, or needs current information (news, releases, prices, docs), call its web_search tool instead of answering from memory." })
|
|
41
|
+
if (message.method === "ping") return reply(message.id, {})
|
|
42
|
+
if (message.method === "tools/list") return reply(message.id, { tools })
|
|
43
|
+
if (message.method === "tools/call") { const params = message.params ?? {}; return reply(message.id, await call(String(params.name), (params.arguments ?? {}) as Record<string, unknown>)) }
|
|
44
|
+
if (message.id !== undefined) reply(message.id, { error: { code: -32601, message: `Method not found: ${message.method}` } })
|
|
45
|
+
}
|