@escalay/kit 0.0.0-stage → 1.8.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/LICENSE +21 -0
- package/README.md +142 -2
- package/cli.mjs +438 -0
- package/docs/STANDARD.md +342 -0
- package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
- package/docs/decisions/0002-node-and-workers.md +45 -0
- package/docs/releasing.md +97 -0
- package/package.json +59 -3
- package/registry.json +548 -0
- package/src/a2a/README.md +25 -0
- package/src/a2a/index.ts +262 -0
- package/src/boards/README.md +35 -0
- package/src/boards/handlers.ts +87 -0
- package/src/boards/index.ts +64 -0
- package/src/boards/refs.ts +23 -0
- package/src/boards/repo.ts +50 -0
- package/src/boards/routes.ts +83 -0
- package/src/boards/schema.ts +28 -0
- package/src/boards/tools.ts +76 -0
- package/src/browser/README.md +31 -0
- package/src/browser/boards-client.ts +74 -0
- package/src/browser/boards-react.ts +73 -0
- package/src/browser/search-client.ts +258 -0
- package/src/browser/search-react.ts +116 -0
- package/src/browser/vector-search-block.tsx +94 -0
- package/src/core/README.md +37 -0
- package/src/core/api.ts +239 -0
- package/src/core/auth.ts +147 -0
- package/src/core/cache.ts +53 -0
- package/src/core/discovery.ts +315 -0
- package/src/core/guards.ts +109 -0
- package/src/core/hal.ts +63 -0
- package/src/core/kv.ts +29 -0
- package/src/core/problem.ts +124 -0
- package/src/db/README.md +39 -0
- package/src/db/guards.ts +61 -0
- package/src/db/index.ts +122 -0
- package/src/guide/README.md +38 -0
- package/src/guide/guide.ts +152 -0
- package/src/guide/reference.ts +204 -0
- package/src/index.ts +15 -0
- package/src/mcp/README.md +34 -0
- package/src/mcp/index.ts +251 -0
- package/src/oauth/README.md +29 -0
- package/src/oauth/index.ts +439 -0
- package/src/oauth/schema.ts +7 -0
- package/src/oauth/store.ts +34 -0
- package/src/pagination/README.md +31 -0
- package/src/pagination/index.ts +65 -0
- package/src/testing/README.md +31 -0
- package/src/testing/index.ts +162 -0
- package/src/vector-search/README.md +27 -0
- package/src/vector-search/index.ts +367 -0
- package/src/vectors/README.md +30 -0
- package/src/vectors/index.ts +326 -0
- package/src/web/README.md +21 -0
- package/src/web/index.ts +100 -0
- package/templates/app/AGENTS.md +41 -0
- package/templates/app/CLAUDE.md +3 -0
- package/templates/app/Dockerfile +12 -0
- package/templates/app/container/entrypoint.sh +7 -0
- package/templates/app/gitignore +4 -0
- package/templates/app/guide/llms.txt +11 -0
- package/templates/app/pnpm-workspace.yaml +5 -0
- package/templates/app/scripts/guide.ts +8 -0
- package/templates/app/src/app.ts +39 -0
- package/templates/app/src/features/index.ts +30 -0
- package/templates/app/src/server.ts +14 -0
- package/templates/app/test/api.test.ts +34 -0
- package/templates/app/tsconfig.json +16 -0
- package/templates/feature/feature.test.ts +19 -0
- package/templates/feature/handlers.ts +43 -0
- package/templates/feature/index.ts +34 -0
- package/templates/feature/repo.ts +24 -0
- package/templates/feature/routes.ts +43 -0
- package/templates/feature/schema.ts +14 -0
- package/templates/feature/tools.ts +46 -0
- package/templates/npm-release/release-please-config.json +60 -0
- package/templates/npm-release/release-please-manifest.json +3 -0
- package/templates/runtime-cloudflare/dev.vars.example +3 -0
- package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
- package/templates/runtime-cloudflare/src/worker.ts +19 -0
- package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
- package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
- package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
- package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
- package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
- package/templates/runtime-cloudflare/vitest.config.ts +23 -0
- package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# mcp
|
|
2
|
+
|
|
3
|
+
An MCP server over the routes: each tool is a thin call to a route, run in-process with the caller's own credentials,
|
|
4
|
+
so the API and MCP can't drift. It also serves resources, prompts, skills and the Server Card.
|
|
5
|
+
|
|
6
|
+
Needs: `core`, `@modelcontextprotocol/server`.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { inProcess, mcpServerFactory, mcpTool, mountMcp } from './kit/mcp/index.js'
|
|
10
|
+
|
|
11
|
+
const getNote = mcpTool({
|
|
12
|
+
name: 'get_note', title: 'One note', description: 'A note with its author, year and tags. Use after a search.',
|
|
13
|
+
input: z.object({ slug: z.string().describe('From a search result.') }),
|
|
14
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
15
|
+
call: ({ slug }) => ({ path: `/api/notes/${encodeURIComponent(slug)}` }),
|
|
16
|
+
summarize: (note) => `${note.title} (${note.year}) · ${note._links.self.href}`,
|
|
17
|
+
})
|
|
18
|
+
mountMcp(app, { factory: mcpServerFactory({ info, instructions, tools: [getNote], callApi: inProcess(app) }), allowedHostnames: [host, 'localhost'] })
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Export | What |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `mcpTool`, `mcpResource` | a tool (route call + summary) and an attachable resource |
|
|
24
|
+
| `routeOf`, `ROUTE_META` | a tool's route (`GET /api/v1/recipes/{id}`), published in `tools/list` as `_meta["api-kit/route"]`; set `route` on a tool whose path is built in steps |
|
|
25
|
+
| `mcpServerFactory` | the server: info, instructions, tools, resources, prompts, skills; `toolsTtlMs` sets how long clients cache `tools/list` (default an hour) |
|
|
26
|
+
| `mountMcp` | `/mcp` (Streamable HTTP, MCP 2026-07-28 with a fallback for older clients) and the Server Card |
|
|
27
|
+
| `inProcess(app)` / `overHttp(origin, key)` | how tools reach the routes: in the same process, or over HTTP for a stdio entry |
|
|
28
|
+
| `callWaiting` | follows a 202 run until it finishes, so a slow route still answers the tool |
|
|
29
|
+
|
|
30
|
+
## Rules
|
|
31
|
+
|
|
32
|
+
- One tool per task-sized route, `snake_case` verbs, a description that says when to use it; arguments named like the
|
|
33
|
+
route's parameters. The summary is what an agent reads first: a few lines per result with ids and links.
|
|
34
|
+
- Every tool runs in the app's tests: `toolsRun` (testing module). Standard: `docs/STANDARD.md` §11.
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An MCP server (spec 2026-07-28, stateless Streamable HTTP; 2025-era clients served by the SDK's fallback) whose tools
|
|
3
|
+
* are thin calls to the app's own HTTP routes, so the API and MCP can never disagree: same validation, same auth,
|
|
4
|
+
* same problem details. A tool answers with a short text an agent reads first, the route's JSON as
|
|
5
|
+
* `structuredContent` (matching `outputSchema` when given), and `resource_link`s for images. The skills folder is
|
|
6
|
+
* served through the Skills extension (SEP-2640) and each playbook also as a prompt.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const tools = [mcpTool({ name: 'search_recipes', title: 'Search recipes', description: '…', input: z.object({ q: z.string() }),
|
|
10
|
+
* call: ({ q }) => ({ path: '/api/v1/search/recipes', query: { q } }), summarize: (page) => … })]
|
|
11
|
+
* mountMcp(app, { factory: mcpServerFactory({ info, instructions, tools, callApi: inProcess(app) }), allowedHostnames: ['cookbook.example.com'] })
|
|
12
|
+
* // a stdio client on a laptop: the same tools over HTTP with its own key
|
|
13
|
+
* serveStdio(mcpServerFactory({ info, instructions, tools, callApi: overHttp(origin, process.env.COOKBOOK_API_KEY!) }))
|
|
14
|
+
*/
|
|
15
|
+
import { createMcpHandler, McpServer, originValidationResponse, ResourceTemplate, SUPPORTED_PROTOCOL_VERSIONS, type AuthInfo, type CallToolResult, type McpServerFactory } from '@modelcontextprotocol/server'
|
|
16
|
+
import type { SkillCatalog } from '@modelcontextprotocol/server/ext/skills'
|
|
17
|
+
import type { z } from '@hono/zod-openapi'
|
|
18
|
+
import type { Context, Hono } from 'hono'
|
|
19
|
+
import { MODERN_PROTOCOL_VERSION, recordMounted, sendPublic, serverCard, SERVER_CARD_TYPE, WELL_KNOWN } from '../core/discovery.js'
|
|
20
|
+
import { originOf, pathWith } from '../core/hal.js'
|
|
21
|
+
|
|
22
|
+
type Query = Record<string, string | number | boolean | string[] | null | undefined>
|
|
23
|
+
export type ApiCall = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; path: string; query?: Query; body?: unknown }
|
|
24
|
+
export type CallApi = (call: ApiCall, auth: AuthInfo | undefined) => Promise<Response>
|
|
25
|
+
|
|
26
|
+
export type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean }
|
|
27
|
+
export type ResourceLinkOut = { uri: string; name: string; title?: string; description?: string; mimeType?: string }
|
|
28
|
+
|
|
29
|
+
export type McpTool<Input extends z.ZodObject = z.ZodObject> = {
|
|
30
|
+
/** `snake_case`, 1–64 characters of `a-z0-9_`. */
|
|
31
|
+
name: string
|
|
32
|
+
title: string
|
|
33
|
+
/** When to use it and what comes back — the model chooses tools by this. */
|
|
34
|
+
description: string
|
|
35
|
+
input: Input
|
|
36
|
+
/** The route's 200 schema, when its shape is fixed: the JSON (always sent as `structuredContent`) must match it. */
|
|
37
|
+
output?: z.ZodTypeAny
|
|
38
|
+
annotations?: ToolAnnotations
|
|
39
|
+
call: (args: z.infer<Input>) => ApiCall
|
|
40
|
+
/** The text an agent reads first: a few lines per result with ids and the links to follow. */
|
|
41
|
+
summarize: (json: any, args: z.infer<Input>) => string
|
|
42
|
+
/** Images or files worth attaching as `resource_link` blocks (at most a handful). */
|
|
43
|
+
links?: (json: any) => ResourceLinkOut[]
|
|
44
|
+
/** The route it calls, `GET /api/v1/recipes/{id}`, when `routeOf` can't read it from `call` (a path built in steps). */
|
|
45
|
+
route?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where a tool's route is published in its MCP listing, so a gateway can tell which tool a guide's route means. */
|
|
49
|
+
export const ROUTE_META = 'api-kit/route'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A tool's route as `METHOD /path/{param}`: its own `route`, or `call` run with each argument as its `{name}`. Undefined
|
|
53
|
+
* when neither works (a call that reads its arguments' values).
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* routeOf(mcpTool({ name: 'get_recipe', input: z.object({ id: z.string() }), call: ({ id }) => ({ path: `/api/v1/recipes/${id}` }), … }))
|
|
57
|
+
* // → 'GET /api/v1/recipes/{id}'
|
|
58
|
+
*/
|
|
59
|
+
export const routeOf = (tool: McpTool): string | undefined => {
|
|
60
|
+
if (tool.route) return tool.route
|
|
61
|
+
try {
|
|
62
|
+
const placeholders = Object.fromEntries(Object.keys(tool.input.shape).map((key) => [key, `{${key}}`]))
|
|
63
|
+
const call = tool.call(placeholders as never)
|
|
64
|
+
return `${call.method ?? 'GET'} ${decodeURIComponent(call.path)}`
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Keeps the input type when building a list of tools. */
|
|
71
|
+
export const mcpTool = <Input extends z.ZodObject>(tool: McpTool<Input>) => tool as unknown as McpTool
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A resource a client can attach (a recipe with its picture, a menu's courses): a URI template and the API calls whose
|
|
75
|
+
* answers make it up — JSON as text, anything else (images) as a blob. Read through the routes with the caller's
|
|
76
|
+
* credentials, like tools.
|
|
77
|
+
*/
|
|
78
|
+
export type McpResource = {
|
|
79
|
+
name: string
|
|
80
|
+
title: string
|
|
81
|
+
description: string
|
|
82
|
+
/** RFC 6570, e.g. `cookbook://recipes/{id}`. */
|
|
83
|
+
uriTemplate: string
|
|
84
|
+
/** The calls whose answers make up the resource, in order; an `optional` one (an image) is left out when it fails. */
|
|
85
|
+
read: (variables: Record<string, string>) => (ApiCall & { optional?: boolean })[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Keeps a resource's shape checked when building the list. */
|
|
89
|
+
export const mcpResource = (resource: McpResource) => resource
|
|
90
|
+
|
|
91
|
+
export type McpPrompt = { name: string; title: string; description: string; arguments?: z.ZodObject; text: (args: Record<string, string>) => string }
|
|
92
|
+
|
|
93
|
+
export type McpServerInfo = { name: string; version: string; title?: string; description?: string; websiteUrl?: string; icons?: { src: string; mimeType?: string; sizes?: string[] }[] }
|
|
94
|
+
|
|
95
|
+
const HOUR = 3_600_000
|
|
96
|
+
|
|
97
|
+
/** A problem+json body (or anything else) as one line an agent can act on. */
|
|
98
|
+
export const problemText = (json: any, status: number) =>
|
|
99
|
+
json && typeof json === 'object' && 'title' in json ? `${json.status ?? status} ${json.code ?? ''} ${json.title}: ${json.detail ?? ''}`.replace(/\s+/g, ' ').trim() : `HTTP ${status}`
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A slow route answers 202 with a run in `Location`: the tool polls it (up to `waitMs`), then asks again for the
|
|
103
|
+
* cached result, so a client sees one answer. Past the wait it says which run to check.
|
|
104
|
+
*/
|
|
105
|
+
export const callWaiting = async (callApi: CallApi, call: ApiCall, auth: AuthInfo | undefined, waitMs: number) => {
|
|
106
|
+
let response = await callApi(call, auth)
|
|
107
|
+
const location = response.headers.get('location')
|
|
108
|
+
if (response.status !== 202 || !location) return { response, pending: null }
|
|
109
|
+
const until = Date.now() + waitMs
|
|
110
|
+
while (Date.now() < until) {
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 3000))
|
|
112
|
+
const run = (await (await callApi({ path: location }, auth)).json().catch(() => null)) as { status?: string } | null
|
|
113
|
+
if (run?.status === 'done') return { response: await callApi(call, auth), pending: null }
|
|
114
|
+
if (run?.status === 'failed') return { response: await callApi({ path: location }, auth), pending: null }
|
|
115
|
+
}
|
|
116
|
+
response = await callApi({ path: location }, auth)
|
|
117
|
+
return { response, pending: location.split('/').pop()! }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A factory that also carries its server info and tools, so `mountMcp` can publish the card and the guide can list the tools. */
|
|
121
|
+
export type AgentMcpFactory = McpServerFactory & { info: McpServerInfo; tools: McpTool[]; resources: McpResource[]; instructions?: string; prompts?: McpPrompt[] }
|
|
122
|
+
|
|
123
|
+
/** `toolsTtlMs`: how long clients may cache `tools/list` (default an hour); shorter when the tools change without a deploy. */
|
|
124
|
+
export const mcpServerFactory = ({ info, instructions, tools, resources = [], prompts = [], skills, callApi, waitMs = 60_000, toolsTtlMs = HOUR }: { info: McpServerInfo; instructions: string; tools: McpTool[]; resources?: McpResource[]; prompts?: McpPrompt[]; skills?: SkillCatalog; callApi: CallApi; waitMs?: number; toolsTtlMs?: number }): AgentMcpFactory => Object.assign(() => {
|
|
125
|
+
// Lists only change with a deploy: let clients cache them for an hour
|
|
126
|
+
const cached = { ttlMs: HOUR, cacheScope: 'private' as const }
|
|
127
|
+
const server = new McpServer(info, { instructions, cacheHints: { 'server/discover': cached, 'tools/list': { ...cached, ttlMs: toolsTtlMs }, 'prompts/list': cached, 'resources/list': cached, 'resources/templates/list': cached } })
|
|
128
|
+
for (const tool of tools) {
|
|
129
|
+
const route = routeOf(tool)
|
|
130
|
+
server.registerTool(
|
|
131
|
+
tool.name,
|
|
132
|
+
{ title: tool.title, description: tool.description, inputSchema: tool.input, ...(tool.output ? { outputSchema: tool.output } : {}), annotations: { title: tool.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, ...tool.annotations }, ...(route ? { _meta: { [ROUTE_META]: route } } : {}) },
|
|
133
|
+
async (args: Record<string, unknown>, ctx): Promise<CallToolResult> => {
|
|
134
|
+
const { response, pending } = await callWaiting(callApi, tool.call(args as never), ctx.http?.authInfo, waitMs)
|
|
135
|
+
const json = await response.json().catch(() => null)
|
|
136
|
+
if (pending) return { content: [{ type: 'text', text: `Still working: run ${pending}. Check it with get_run, then call ${tool.name} again with the same arguments (it answers at once when done).` }] }
|
|
137
|
+
if (!response.ok) return { isError: true, content: [{ type: 'text', text: problemText(json, response.status) }] }
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: 'text', text: tool.summarize(json, args as never) }, ...(tool.links?.(json) ?? []).map((link) => ({ type: 'resource_link' as const, ...link }))],
|
|
140
|
+
...(json && typeof json === 'object' ? { structuredContent: json } : {}),
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
for (const prompt of prompts) {
|
|
146
|
+
server.registerPrompt(prompt.name, { title: prompt.title, description: prompt.description, ...(prompt.arguments ? { argsSchema: prompt.arguments } : {}) } as never, ((args: Record<string, string> = {}) => ({
|
|
147
|
+
description: prompt.description,
|
|
148
|
+
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: prompt.text(args) } }],
|
|
149
|
+
})) as never)
|
|
150
|
+
}
|
|
151
|
+
for (const resource of resources) {
|
|
152
|
+
server.registerResource(
|
|
153
|
+
resource.name,
|
|
154
|
+
new ResourceTemplate(resource.uriTemplate, { list: undefined }),
|
|
155
|
+
{ title: resource.title, description: resource.description, cacheHint: cached },
|
|
156
|
+
async (uri, variables, ctx) => {
|
|
157
|
+
const auth = ctx.http?.authInfo
|
|
158
|
+
const values = Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, Array.isArray(value) ? value[0]! : String(value)]))
|
|
159
|
+
const parts = await Promise.all(
|
|
160
|
+
resource.read(values).map(async ({ optional, ...call }) => {
|
|
161
|
+
const response = await callApi(call, auth)
|
|
162
|
+
if (!response.ok && optional) return null
|
|
163
|
+
if (!response.ok) throw new Error(problemText(await response.json().catch(() => null), response.status))
|
|
164
|
+
const type = response.headers.get('content-type') ?? 'application/octet-stream'
|
|
165
|
+
return /json|text|markdown/.test(type)
|
|
166
|
+
? { uri: uri.href, mimeType: type.split(';')[0]!, text: await response.text() }
|
|
167
|
+
: { uri: uri.href, mimeType: type.split(';')[0]!, blob: Buffer.from(await response.arrayBuffer()).toString('base64') }
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
return { contents: parts.filter((part) => part !== null) }
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
skills?.attach(server)
|
|
175
|
+
return server
|
|
176
|
+
}, { info, tools, resources, instructions, prompts })
|
|
177
|
+
|
|
178
|
+
export const FORWARDED = ['authorization', 'cf-access-jwt-assertion'] as const
|
|
179
|
+
|
|
180
|
+
/** Tools run the app's routes in-process with the caller's own credentials: no network hop, same auth decision. */
|
|
181
|
+
export const inProcess = (app: { request: (input: string, init?: RequestInit) => Response | Promise<Response> }): CallApi => async (call, auth) => {
|
|
182
|
+
const forwarded = (auth?.extra?.headers ?? {}) as Record<string, string>
|
|
183
|
+
return app.request(pathWith(call.path, call.query), {
|
|
184
|
+
method: call.method ?? 'GET',
|
|
185
|
+
headers: { accept: 'application/json', ...(call.body === undefined ? {} : { 'content-type': 'application/json' }), ...forwarded },
|
|
186
|
+
...(call.body === undefined ? {} : { body: JSON.stringify(call.body) }),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** For a local stdio server: the same tools against the remote API with the client's own key. */
|
|
191
|
+
export const overHttp = (origin: string, key: string, fetchImpl: typeof fetch = fetch): CallApi => (call) =>
|
|
192
|
+
fetchImpl(origin + pathWith(call.path, call.query), {
|
|
193
|
+
method: call.method ?? 'GET',
|
|
194
|
+
headers: { accept: 'application/json', authorization: `Bearer ${key}`, ...(call.body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
195
|
+
...(call.body === undefined ? {} : { body: JSON.stringify(call.body) }),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 2026-07-28 has no `initialize` (`server/discover` replaced it), so the SDK answers -32601, which reads to an agent as
|
|
200
|
+
* a broken server. Agents still open with it out of habit: answer with what `server/discover` would, and say so.
|
|
201
|
+
*/
|
|
202
|
+
const modernInitialize = async (c: Context, factory: AgentMcpFactory) => {
|
|
203
|
+
if (c.req.method !== 'POST' || c.req.header('mcp-protocol-version') !== MODERN_PROTOCOL_VERSION) return null
|
|
204
|
+
const message = (await c.req.raw.clone().json().catch(() => null)) as { jsonrpc?: string; id?: string | number; method?: string } | null
|
|
205
|
+
if (message?.method !== 'initialize' || message.id === undefined) return null
|
|
206
|
+
const result = {
|
|
207
|
+
protocolVersion: MODERN_PROTOCOL_VERSION,
|
|
208
|
+
// As the server itself declares them: an app may announce changes on a listen stream (`handler.notify`)
|
|
209
|
+
capabilities: { tools: { listChanged: true }, ...(factory.prompts?.length ? { prompts: { listChanged: true } } : {}), ...(factory.resources.length ? { resources: { listChanged: true } } : {}) },
|
|
210
|
+
serverInfo: factory.info,
|
|
211
|
+
instructions: `${factory.instructions ?? ''}\n\nThis server speaks MCP ${MODERN_PROTOCOL_VERSION}, which is stateless: no initialize or session is needed. Call tools/list or any method directly with the MCP-Protocol-Version and Mcp-Method headers (server/discover returns this same information).`.trim(),
|
|
212
|
+
}
|
|
213
|
+
return c.json({ jsonrpc: '2.0', id: message.id, result })
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Serves MCP at `path` (POST; GET and DELETE answer 405 as the spec asks). Authentication has already run (the app's
|
|
218
|
+
* `authenticate` guards this path like any other); the caller's credentials ride along in `authInfo` so tools reuse
|
|
219
|
+
* them. Browsers' Origin is checked against `allowedHostnames`.
|
|
220
|
+
*/
|
|
221
|
+
export const mountMcp = (app: Hono<any, any, any>, { path = '/mcp', factory, allowedHostnames }: { path?: string; factory: AgentMcpFactory; allowedHostnames: string[] }) => {
|
|
222
|
+
const handler = createMcpHandler(factory, { responseMode: 'auto' })
|
|
223
|
+
// The Server Card (SEP-2127): at its reserved place beside the endpoint, and where domain scanners look
|
|
224
|
+
const card = (origin: string) => serverCard(origin, path, factory.info, [MODERN_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS])
|
|
225
|
+
recordMounted(app, { mcp: { path, card, tools: factory.tools, resources: factory.resources } })
|
|
226
|
+
for (const at of [`${path}/server-card`, WELL_KNOWN.serverCard]) app.get(at, (c: Context) => sendPublic(c, JSON.stringify(card(originOf(c)), null, 2), SERVER_CARD_TYPE))
|
|
227
|
+
app.all(path, async (c: Context) => {
|
|
228
|
+
const rejected = originValidationResponse(c.req.raw, allowedHostnames)
|
|
229
|
+
if (rejected) return rejected
|
|
230
|
+
const greeting = await modernInitialize(c, factory)
|
|
231
|
+
if (greeting) return greeting
|
|
232
|
+
const caller = c.get('caller' as never) as { kind: string; name?: string; email?: string } | undefined
|
|
233
|
+
const authInfo: AuthInfo = {
|
|
234
|
+
token: (c.req.header('authorization') ?? '').replace(/^bearer\s+/i, ''),
|
|
235
|
+
clientId: caller?.name ?? caller?.email ?? 'anonymous',
|
|
236
|
+
scopes: [],
|
|
237
|
+
// A static key never expires; the SDK treats a missing expiry as expired
|
|
238
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
239
|
+
// Tools call the routes in-process: pass the caller's credentials, and the public origin so links stay absolute
|
|
240
|
+
extra: {
|
|
241
|
+
headers: {
|
|
242
|
+
...Object.fromEntries(FORWARDED.flatMap((name) => (c.req.header(name) ? [[name, c.req.header(name)!]] : []))),
|
|
243
|
+
'x-forwarded-proto': new URL(originOf(c)).protocol.replace(':', ''),
|
|
244
|
+
'x-forwarded-host': new URL(originOf(c)).host,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
return handler.fetch(c.req.raw, { authInfo })
|
|
249
|
+
})
|
|
250
|
+
return handler
|
|
251
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# oauth
|
|
2
|
+
|
|
3
|
+
The app as its own OAuth server, so MCP clients (Claude, ChatGPT) and agents connect without a pasted key: dynamic
|
|
4
|
+
client registration, authorization code with PKCE, refresh rotation, revocation, agent registration through
|
|
5
|
+
`/auth.md` with a 6-digit owner claim, ES256 tokens with `read` / `write` scopes, and the metadata documents.
|
|
6
|
+
|
|
7
|
+
Needs: `core`, `db`.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { accessOwner, createAgentApi, createOAuth, dbOAuthStore, generateOAuthKey } from './kit/index.js'
|
|
11
|
+
|
|
12
|
+
const oauth = env.OAUTH_SIGNING_KEY
|
|
13
|
+
? createOAuth({ name: 'Notes', signingKey: env.OAUTH_SIGNING_KEY, store: await dbOAuthStore(db), owner: access ? accessOwner(access) : async () => null })
|
|
14
|
+
: undefined
|
|
15
|
+
const app = createAgentApi({ …, oauth }) // mounts /oauth/*, the metadata, and accepts its tokens
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| File | What |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `index.ts` | `createOAuth` (the server; `revokeClient` is the owner's kill switch), `generateOAuthKey` (a new signing key) |
|
|
21
|
+
| `store.ts` | `dbOAuthStore(db)` (in the app's database; drops expired rows at start), `memoryOAuthStore` |
|
|
22
|
+
| `schema.ts` | the `oauth` table and `OAUTH_MIGRATIONS` |
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
- The signing key lives in the app's secrets, never in the repo; rotating it signs everyone out.
|
|
27
|
+
- `owner` decides who may approve: behind Cloudflare Access, `accessOwner(access)` (the signed-in email).
|
|
28
|
+
- Keep the OAuth paths open (`OAUTH_OPEN_PATHS`); `createAgentApi` does it when `oauth` is set, private mode included.
|
|
29
|
+
- Standard: `docs/STANDARD.md` §7.
|