@nshipster/sosumi 1.0.5 → 1.0.7

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 CHANGED
@@ -93,7 +93,7 @@ See [the website](https://sosumi.ai/#clients) for client-specific instructions.
93
93
  - Returns structured results with titles, URLs, descriptions, breadcrumbs, and tags
94
94
 
95
95
  - `fetchAppleDocumentation` - Fetches Apple Developer documentation and Human Interface Guidelines by path
96
- - Parameters: `path` (string) - Documentation path (e.g., '/documentation/swift', 'swiftui/view', 'design/human-interface-guidelines/foundations/color')
96
+ - Parameters: `path` (string) - Documentation path (e.g., '/documentation/swift', '/documentation/swiftui/view', '/design/human-interface-guidelines/foundations/color')
97
97
  - Returns content as Markdown
98
98
 
99
99
  - `fetchAppleVideoTranscript` - Fetches video transcripts, including WWDC sessions
@@ -234,6 +234,49 @@ with two environment variables (both newline-delimited):
234
234
  > Hostname-based private-network checks cannot fully prevent DNS rebinding.
235
235
  > Set an explicit `EXTERNAL_DOC_HOST_ALLOWLIST` in production.
236
236
 
237
+ ### Web Bot Auth
238
+
239
+ Sosumi identifies itself
240
+ when it fetches from external Swift-DocC hosts on a user's behalf
241
+ using [Web Bot Auth](https://datatracker.ietf.org/wg/webbotauth/about/),
242
+ so those hosts can cryptographically verify the traffic.
243
+
244
+ - The public key set is published at
245
+ [`/.well-known/http-message-signatures-directory`](https://sosumi.ai/.well-known/http-message-signatures-directory).
246
+ - Requests to external hosts are signed
247
+ with an Ed25519 [HTTP Message Signature (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421)
248
+ and carry `Signature-Agent`, `Signature-Input`, and `Signature` headers.
249
+
250
+ To enable signing on your own deployment,
251
+ provide an Ed25519 private key (as a JSON Web Key)
252
+ via the `WEB_BOT_AUTH_KEY` secret.
253
+
254
+ Generate a key (the JSON Web Key is the format `WEB_BOT_AUTH_KEY` expects),
255
+ then paste the printed value when `wrangler secret put` prompts for it:
256
+
257
+ ```bash
258
+ node -e 'crypto.subtle.generateKey({name:"Ed25519"},true,["sign","verify"]).then(async k=>{const j=await crypto.subtle.exportKey("jwk",k.privateKey);console.log(JSON.stringify({kty:j.kty,crv:j.crv,x:j.x,d:j.d}))})'
259
+ npx wrangler secret put WEB_BOT_AUTH_KEY
260
+ ```
261
+
262
+ Or generate and upload in a single step,
263
+ which avoids writing the key to disk or shell history:
264
+
265
+ ```bash
266
+ node -e 'crypto.subtle.generateKey({name:"Ed25519"},true,["sign","verify"]).then(async k=>{const j=await crypto.subtle.exportKey("jwk",k.privateKey);console.log(JSON.stringify({kty:j.kty,crv:j.crv,x:j.x,d:j.d}))})' \
267
+ | npx wrangler secret put WEB_BOT_AUTH_KEY
268
+ ```
269
+
270
+ The matching public key and its `kid` (JWK thumbprint) are derived automatically,
271
+ so no key material lives in the repository.
272
+ Set the optional `SIGNATURE_AGENT` var to override the advertised origin
273
+ (defaults to `https://sosumi.ai`).
274
+ When no key is configured, requests are simply sent unsigned.
275
+
276
+ Cloudflare provides
277
+ [built-in verification](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/)
278
+ for these signatures.
279
+
237
280
  ## Development
238
281
 
239
282
  ### Testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nshipster/sosumi",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Making Apple docs AI-readable",
5
5
  "license": "MIT",
6
6
  "author": "NSHipster",
@@ -61,7 +61,9 @@
61
61
  "hono": "^4.9.4",
62
62
  "robots-parser": "^3.0.1",
63
63
  "tsx": "^4.21.0",
64
- "zod": "^3"
64
+ "web-bot-auth": "^0.1.3",
65
+ "zod": "^3",
66
+ "zod-to-json-schema": "^3.25.2"
65
67
  },
66
68
  "devDependencies": {
67
69
  "@biomejs/biome": "2.2.2",
package/public/index.html CHANGED
@@ -36,6 +36,8 @@
36
36
 
37
37
  <link rel="preload" href="/sosumi.m4a" as="audio" type="audio/mp4">
38
38
 
39
+ <script src="/webmcp.js" defer></script>
40
+
39
41
  <style>
40
42
  :root {
41
43
  color-scheme: light dark;
@@ -809,7 +811,7 @@
809
811
  <dl>
810
812
  <dt><code>fetchAppleDocumentation</code></dt>
811
813
  <dd><strong>Fetches Apple Developer documentation and Human Interface Guidelines by path</strong>
812
- <br>Parameters: <code>path</code> (string) - Documentation path (e.g., '/documentation/swift', 'design/human-interface-guidelines/foundations/color')
814
+ <br>Parameters: <code>path</code> (string) - Documentation path (e.g., '/documentation/swift', '/documentation/swiftui/view', '/design/human-interface-guidelines/foundations/color')
813
815
  <br><em>Returns content as Markdown</em>
814
816
  </dd>
815
817
  </dl>
package/public/llms.txt CHANGED
@@ -121,7 +121,7 @@ you can run this command to proxy over stdio:
121
121
  - Returns structured results with titles, URLs, descriptions, breadcrumbs, and tags
122
122
 
123
123
  - `fetchAppleDocumentation` - Fetches Apple Developer documentation and Human Interface Guidelines by path
124
- - Parameters: `path` (string) - Documentation path (e.g., '/documentation/swift', 'design/human-interface-guidelines/foundations/color')
124
+ - Parameters: `path` (string) - Documentation path (e.g., '/documentation/swift', '/documentation/swiftui/view', '/design/human-interface-guidelines/foundations/color')
125
125
  - Returns content as Markdown
126
126
 
127
127
  - `fetchAppleVideoTranscript` - Fetches video transcripts, including WWDC sessions, by video path
package/public/robots.txt CHANGED
@@ -1,4 +1,5 @@
1
1
  User-agent: *
2
+ Content-Signal: search=yes, ai-input=yes, ai-train=no
2
3
  Allow: /
3
4
  Disallow: /documentation/
4
5
  Disallow: /external/
@@ -0,0 +1,71 @@
1
+ (function () {
2
+ const ctx =
3
+ (typeof navigator !== "undefined" && navigator.modelContext) || document.modelContext
4
+ if (!ctx) return
5
+ if (typeof ctx.registerTool !== "function" && typeof ctx.provideContext !== "function") return
6
+
7
+ async function runHttpTool(http, input) {
8
+ let pathname = http.path
9
+ if (http.pathFrom) {
10
+ const value = String(input[http.pathFrom] ?? "")
11
+ if (http.pathPrefix) {
12
+ pathname = http.pathPrefix + value.replace(/^\//, "")
13
+ } else {
14
+ pathname = value.startsWith("/") ? value : "/" + value
15
+ }
16
+ }
17
+ const url = new URL(pathname, location.origin)
18
+ if (http.query) {
19
+ for (const [param, key] of Object.entries(http.query)) {
20
+ url.searchParams.set(param, String(input[key] ?? ""))
21
+ }
22
+ }
23
+ const response = await fetch(url, { headers: { Accept: "application/json" } })
24
+ const body = await response.text()
25
+ let data
26
+ try {
27
+ data = JSON.parse(body)
28
+ } catch {
29
+ data = body
30
+ }
31
+ const text =
32
+ data && typeof data === "object" && data !== null && "content" in data
33
+ ? String(data.content)
34
+ : typeof data === "string"
35
+ ? data
36
+ : JSON.stringify(data, null, 2)
37
+ return { content: [{ type: "text", text }] }
38
+ }
39
+
40
+ function registerTool(tool) {
41
+ const registration = {
42
+ name: tool.name,
43
+ title: tool.title,
44
+ description: tool.description,
45
+ inputSchema: tool.inputSchema,
46
+ annotations: { readOnlyHint: tool.readOnly },
47
+ execute: (input) => runHttpTool(tool.http, input),
48
+ }
49
+ try {
50
+ if (typeof ctx.registerTool === "function") {
51
+ ctx.registerTool(registration)
52
+ } else if (typeof ctx.provideContext === "function") {
53
+ ctx.provideContext({ tools: [registration] })
54
+ }
55
+ } catch {
56
+ // Ignore duplicate or unsupported registrations.
57
+ }
58
+ }
59
+
60
+ fetch("/webmcp/manifest.json")
61
+ .then((response) => {
62
+ if (!response.ok) throw new Error("Failed to load WebMCP manifest")
63
+ return response.json()
64
+ })
65
+ .then((tools) => {
66
+ for (const tool of tools) {
67
+ registerTool(tool)
68
+ }
69
+ })
70
+ .catch(() => {})
71
+ })()
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { cache } from "hono/cache"
5
5
  import { cors } from "hono/cors"
6
6
  import { HTTPException } from "hono/http-exception"
7
7
  import { trimTrailingSlash } from "hono/trailing-slash"
8
+ import { buildAgentCard } from "./lib/a2a"
8
9
  import {
9
10
  decodeExternalTargetPath,
10
11
  ExternalAccessError,
@@ -19,7 +20,7 @@ import {
19
20
  renderHIGFromJSON,
20
21
  renderHIGTableOfContents,
21
22
  } from "./lib/hig"
22
- import { createMcpServer } from "./lib/mcp"
23
+ import { createMcpServer, MCP_SERVER_INFO } from "./lib/mcp"
23
24
  import { fetchJSONData, renderFromJSON } from "./lib/reference"
24
25
  import { searchAppleDeveloperDocs } from "./lib/search"
25
26
  import {
@@ -32,17 +33,28 @@ import {
32
33
  } from "./lib/skill"
33
34
  import { generateAppleDocUrl, isValidAppleDocUrl, normalizeDocumentationPath } from "./lib/url"
34
35
  import { fetchVideoTranscriptMarkdown, TranscriptNotFoundError } from "./lib/video"
36
+ import {
37
+ configureWebBotAuth,
38
+ DIRECTORY_PATH as WEB_BOT_AUTH_DIRECTORY_PATH,
39
+ webBotAuthDirectory,
40
+ } from "./lib/webbotauth"
41
+ import { buildWebMcpManifest } from "./lib/webmcp"
35
42
 
36
43
  interface Env {
37
44
  ASSETS: Fetcher
38
45
  NODE_ENV: string
39
46
  EXTERNAL_DOC_HOST_ALLOWLIST?: string
40
47
  EXTERNAL_DOC_HOST_BLOCKLIST?: string
48
+ WEB_BOT_AUTH_KEY?: string
49
+ SIGNATURE_AGENT?: string
41
50
  }
42
51
 
43
52
  const app = new Hono<{ Bindings: Env }>()
44
53
 
45
54
  app.use("*", async (c, next) => {
55
+ // Prime Web Bot Auth signing before any route or outbound fetch runs.
56
+ configureWebBotAuth(c.env)
57
+
46
58
  await next()
47
59
 
48
60
  // Security headers
@@ -59,6 +71,19 @@ app.use("*", async (c, next) => {
59
71
  if (c.env.NODE_ENV === "development") {
60
72
  c.header("Cache-Control", "no-store")
61
73
  }
74
+
75
+ if (c.req.path === "/") {
76
+ c.header(
77
+ "Link",
78
+ [
79
+ '</.well-known/api-catalog>; rel="api-catalog"',
80
+ '</.well-known/agent-card.json>; rel="service-desc"; type="application/json"',
81
+ '</.well-known/mcp/server-card.json>; rel="service-desc"; type="application/json"',
82
+ '</SKILL.md>; rel="service-doc"',
83
+ '</llms.txt>; rel="alternate"; type="text/markdown"',
84
+ ].join(", "),
85
+ )
86
+ }
62
87
  })
63
88
 
64
89
  app.use("*", cors())
@@ -146,6 +171,92 @@ app.all(`/.well-known/agent-skills/${SKILL_NAME}/SKILL.md`, async (c) => {
146
171
  })
147
172
  })
148
173
 
174
+ app.get(WEB_BOT_AUTH_DIRECTORY_PATH, async (c) => {
175
+ const directory = await webBotAuthDirectory(c.req.url)
176
+ if (!directory) {
177
+ throw new HTTPException(404, { message: "Web Bot Auth key directory is not configured." })
178
+ }
179
+
180
+ return c.json(directory.body, 200, {
181
+ "Access-Control-Allow-Origin": "*",
182
+ ...directory.headers,
183
+ })
184
+ })
185
+
186
+ const discoveryHeaders = {
187
+ "Access-Control-Allow-Origin": "*",
188
+ "Cache-Control": "public, max-age=300, s-maxage=600",
189
+ } as const
190
+
191
+ app.get("/.well-known/api-catalog", (c) => {
192
+ const origin = new URL(c.req.url).origin
193
+
194
+ return c.json(
195
+ {
196
+ linkset: [
197
+ {
198
+ anchor: `${origin}/mcp`,
199
+ "service-desc": [
200
+ {
201
+ href: `${origin}/.well-known/mcp/server-card.json`,
202
+ type: "application/json",
203
+ },
204
+ ],
205
+ "service-doc": [{ href: `${origin}/SKILL.md`, type: "text/markdown" }],
206
+ status: [{ href: `${origin}/` }],
207
+ },
208
+ {
209
+ anchor: `${origin}/documentation`,
210
+ "service-doc": [{ href: `${origin}/SKILL.md`, type: "text/markdown" }],
211
+ },
212
+ ],
213
+ },
214
+ 200,
215
+ {
216
+ ...discoveryHeaders,
217
+ "Content-Type": "application/linkset+json; charset=utf-8",
218
+ },
219
+ )
220
+ })
221
+
222
+ app.get("/.well-known/mcp/server-card.json", (c) => {
223
+ const origin = new URL(c.req.url).origin
224
+
225
+ return c.json(
226
+ {
227
+ serverInfo: MCP_SERVER_INFO,
228
+ transport: {
229
+ type: "streamable-http",
230
+ endpoint: `${origin}/mcp`,
231
+ },
232
+ capabilities: {
233
+ tools: {},
234
+ },
235
+ },
236
+ 200,
237
+ {
238
+ ...discoveryHeaders,
239
+ "Content-Type": "application/json; charset=utf-8",
240
+ },
241
+ )
242
+ })
243
+
244
+ app.get("/.well-known/agent-card.json", (c) => {
245
+ const origin = new URL(c.req.url).origin
246
+
247
+ return c.json(buildAgentCard(origin), 200, {
248
+ ...discoveryHeaders,
249
+ "Content-Type": "application/json; charset=utf-8",
250
+ })
251
+ })
252
+
253
+ app.get("/webmcp/manifest.json", (c) =>
254
+ c.json(buildWebMcpManifest(), 200, {
255
+ ...discoveryHeaders,
256
+ "Content-Type": "application/json; charset=utf-8",
257
+ }),
258
+ )
259
+
149
260
  app.get("/bot", (c) => c.redirect("/#bot", 302))
150
261
 
151
262
  app.all("/mcp", async (c) => {
package/src/lib/a2a.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { MCP_SERVER_INFO, TOOL_DEFINITIONS } from "./mcp"
2
+
3
+ /**
4
+ * The A2A protocol version each interface exposes, as `major.minor`.
5
+ * See https://a2a-protocol.org/latest/specification/
6
+ */
7
+ const A2A_PROTOCOL_VERSION = "0.3"
8
+
9
+ /**
10
+ * The transport binding advertised for the agent's interface.
11
+ * Sosumi exposes its documentation service over plain HTTP requests,
12
+ * so the `HTTP+JSON` binding is the closest of A2A's officially supported bindings.
13
+ */
14
+ const TRANSPORT = "HTTP+JSON"
15
+
16
+ const AGENT_DESCRIPTION =
17
+ "Making Apple docs AI-readable. " +
18
+ "Sosumi converts Apple Developer documentation, Human Interface Guidelines, " +
19
+ "WWDC session transcripts, and public Swift-DocC sites " +
20
+ "into clean Markdown for AI agents."
21
+
22
+ /** Keyword tags describing each skill's capabilities. */
23
+ const SKILL_TAGS: Record<string, string[]> = {
24
+ searchAppleDocumentation: ["apple", "search", "documentation"],
25
+ fetchAppleDocumentation: ["apple", "documentation", "markdown", "hig"],
26
+ fetchExternalDocumentation: ["swift-docc", "documentation", "markdown"],
27
+ fetchAppleVideoTranscript: ["apple", "wwdc", "video", "transcript"],
28
+ }
29
+
30
+ /** Example prompts illustrating how each skill is used. */
31
+ const SKILL_EXAMPLES: Record<string, string[]> = {
32
+ searchAppleDocumentation: ["Search Apple documentation for URLSession"],
33
+ fetchAppleDocumentation: ["Fetch /documentation/swiftui/view as Markdown"],
34
+ fetchExternalDocumentation: [
35
+ "Fetch https://apple.github.io/swift-argument-parser/documentation/argumentparser",
36
+ ],
37
+ fetchAppleVideoTranscript: ["Fetch the transcript for /videos/play/wwdc2021/10133"],
38
+ }
39
+
40
+ interface AgentInterface {
41
+ url: string
42
+ /** The A2A protocol version this interface exposes. */
43
+ protocolVersion: string
44
+ /** Transport binding. Named `protocolBinding` by the current A2A proto schema. */
45
+ protocolBinding: string
46
+ /** Alias of `protocolBinding` for clients reading the published JSON schema's `transport`. */
47
+ transport: string
48
+ }
49
+
50
+ /**
51
+ * Build an A2A Agent Card for the given origin.
52
+ * Conforms to the A2A Agent Card schema for agent-to-agent discovery.
53
+ * https://a2a-protocol.org/latest/topics/agent-discovery/
54
+ */
55
+ export function buildAgentCard(origin: string) {
56
+ const service: AgentInterface = {
57
+ url: origin,
58
+ protocolVersion: A2A_PROTOCOL_VERSION,
59
+ protocolBinding: TRANSPORT,
60
+ transport: TRANSPORT,
61
+ }
62
+
63
+ const skills = Object.values(TOOL_DEFINITIONS).map((def) => ({
64
+ // Convert the camelCase tool name to a kebab-case skill id.
65
+ id: def.name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(),
66
+ name: def.title,
67
+ description: def.description,
68
+ tags: SKILL_TAGS[def.name] ?? ["apple", "documentation"],
69
+ examples: SKILL_EXAMPLES[def.name],
70
+ }))
71
+
72
+ return {
73
+ name: MCP_SERVER_INFO.name,
74
+ description: AGENT_DESCRIPTION,
75
+ version: MCP_SERVER_INFO.version,
76
+ supportedInterfaces: [service],
77
+ provider: {
78
+ organization: "NSHipster",
79
+ url: origin,
80
+ },
81
+ documentationUrl: `${origin}/SKILL.md`,
82
+ capabilities: {
83
+ streaming: false,
84
+ pushNotifications: false,
85
+ },
86
+ defaultInputModes: ["text/plain"],
87
+ defaultOutputModes: ["text/markdown", "text/plain"],
88
+ skills,
89
+ }
90
+ }
@@ -1,5 +1,6 @@
1
1
  import { renderFromJSON } from "../reference"
2
2
  import type { AppleDocJSON } from "../types"
3
+ import { webBotAuthHeaders } from "../webbotauth"
3
4
  import {
4
5
  assertExternalDocumentationAccess,
5
6
  ExternalAccessError,
@@ -42,6 +43,7 @@ export async function fetchExternalDocCJSON(
42
43
  headers: {
43
44
  "User-Agent": EXTERNAL_DOC_USER_AGENT,
44
45
  Accept: "application/json",
46
+ ...(await webBotAuthHeaders("GET", jsonUrl)),
45
47
  },
46
48
  })
47
49
 
@@ -88,6 +90,7 @@ export async function fetchRobotsPolicy(
88
90
  headers: {
89
91
  "User-Agent": userAgent,
90
92
  Accept: "text/plain, text/*;q=0.9, */*;q=0.1",
93
+ ...(await webBotAuthHeaders("GET", robotsUrl)),
91
94
  },
92
95
  })
93
96