@better-forge/mcp 0.1.0 → 0.1.1
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 -21
- package/README.md +14 -14
- package/package.json +54 -54
- package/src/endpoint.ts +163 -163
- package/src/guard.ts +19 -19
- package/src/index.ts +6 -6
- package/src/output.ts +54 -54
- package/src/tools.ts +54 -54
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BetterStatusPage contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
# @better-forge/mcp
|
|
2
|
-
|
|
3
|
-
A Model Context Protocol endpoint for a Fastify app (Streamable HTTP, stateless).
|
|
4
|
-
|
|
5
|
-
- **Bearer only.** Cookies are never read, so a web page cannot use the endpoint; a request with a foreign `Origin`, or
|
|
6
|
-
a `Host` that is not the public URL, is refused.
|
|
7
|
-
- **Scoped tools.** Each tool names the permission it needs and whether it only reads, writes, or is destructive (announced to the client as annotations); a tool can only GET, POST, PATCH or PUT, never DELETE; `tools/list` shows a caller only the tools its token may use.
|
|
8
|
-
- **The host's own API does the work.** A tool reads through `ctx.get(path)`, a request to the same app with the caller's
|
|
9
|
-
`Authorization` header, so validation, permissions, rate limits and audit stay where they are.
|
|
10
|
-
- **Bounded, plain-data output.** Strings are stripped of control, bidi and zero-width characters and cut to 2000
|
|
11
|
-
characters; a result is cut to 64 KB (a list under `items` is shortened first).
|
|
12
|
-
|
|
13
|
-
The package knows nothing about what the tools are about; the product supplies them with `defineTool` and the
|
|
14
|
-
authentication with `authenticate`. Depends on: `@better-forge/contracts`.
|
|
1
|
+
# @better-forge/mcp
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol endpoint for a Fastify app (Streamable HTTP, stateless).
|
|
4
|
+
|
|
5
|
+
- **Bearer only.** Cookies are never read, so a web page cannot use the endpoint; a request with a foreign `Origin`, or
|
|
6
|
+
a `Host` that is not the public URL, is refused.
|
|
7
|
+
- **Scoped tools.** Each tool names the permission it needs and whether it only reads, writes, or is destructive (announced to the client as annotations); a tool can only GET, POST, PATCH or PUT, never DELETE; `tools/list` shows a caller only the tools its token may use.
|
|
8
|
+
- **The host's own API does the work.** A tool reads through `ctx.get(path)`, a request to the same app with the caller's
|
|
9
|
+
`Authorization` header, so validation, permissions, rate limits and audit stay where they are.
|
|
10
|
+
- **Bounded, plain-data output.** Strings are stripped of control, bidi and zero-width characters and cut to 2000
|
|
11
|
+
characters; a result is cut to 64 KB (a list under `items` is shortened first).
|
|
12
|
+
|
|
13
|
+
The package knows nothing about what the tools are about; the product supplies them with `defineTool` and the
|
|
14
|
+
authentication with `authenticate`. Depends on: `@better-forge/contracts`.
|
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@better-forge/mcp",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Model Context Protocol endpoint for a Fastify app: Bearer-only access, scoped tools, bounded and sanitised output.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
-
"directory": "packages/mcp"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/mcp#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
-
},
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public"
|
|
17
|
-
},
|
|
18
|
-
"type": "module",
|
|
19
|
-
"main": "./dist/index.js",
|
|
20
|
-
"types": "./dist/index.d.ts",
|
|
21
|
-
"exports": {
|
|
22
|
-
".": {
|
|
23
|
-
"types": "./dist/index.d.ts",
|
|
24
|
-
"import": "./dist/index.js"
|
|
25
|
-
},
|
|
26
|
-
"./package.json": "./package.json"
|
|
27
|
-
},
|
|
28
|
-
"files": [
|
|
29
|
-
"dist",
|
|
30
|
-
"src",
|
|
31
|
-
"!src/**/*.test.*"
|
|
32
|
-
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "tsc -p tsconfig.json",
|
|
35
|
-
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
-
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"@modelcontextprotocol/sdk": "1.32.1",
|
|
39
|
-
"@better-forge/contracts": "0.1.
|
|
40
|
-
},
|
|
41
|
-
"peerDependencies": {
|
|
42
|
-
"fastify": "^5.3.2",
|
|
43
|
-
"zod": "^4.4.3"
|
|
44
|
-
},
|
|
45
|
-
"devDependencies": {
|
|
46
|
-
"@types/node": "^26.6.2",
|
|
47
|
-
"fastify": "^5.3.2",
|
|
48
|
-
"typescript": "^5.7.3",
|
|
49
|
-
"zod": "^4.4.3"
|
|
50
|
-
},
|
|
51
|
-
"engines": {
|
|
52
|
-
"node": ">=26.10.0"
|
|
53
|
-
}
|
|
54
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-forge/mcp",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Model Context Protocol endpoint for a Fastify app: Bearer-only access, scoped tools, bounded and sanitised output.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/BElluu/Better-Forge.git",
|
|
9
|
+
"directory": "packages/mcp"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/mcp#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BElluu/Better-Forge/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"!src/**/*.test.*"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
39
|
+
"@better-forge/contracts": "0.1.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"fastify": "^5.3.2",
|
|
43
|
+
"zod": "^4.4.3"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^26.6.2",
|
|
47
|
+
"fastify": "^5.3.2",
|
|
48
|
+
"typescript": "^5.7.3",
|
|
49
|
+
"zod": "^4.4.3"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=26.10.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/endpoint.ts
CHANGED
|
@@ -1,163 +1,163 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
3
|
-
import { tokenAllows } from '@better-forge/contracts'
|
|
4
|
-
import { z } from 'zod'
|
|
5
|
-
import type { FastifyInstance, FastifyRequest, onRequestAsyncHookHandler } from 'fastify'
|
|
6
|
-
import { crossSiteProblem } from './guard.js'
|
|
7
|
-
import { boundedJson, cleanDeep, McpToolError, cleanText } from './output.js'
|
|
8
|
-
import { SAFE_PATH, type McpTool, type McpToolContext } from './tools.js'
|
|
9
|
-
|
|
10
|
-
/** Who is calling: the permissions of the token and the header to hand on to the host's own API. */
|
|
11
|
-
export interface McpCaller {
|
|
12
|
-
authorization: string
|
|
13
|
-
scopes: readonly string[]
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface McpEndpointOptions {
|
|
17
|
-
/** Path of the endpoint. */
|
|
18
|
-
path: string
|
|
19
|
-
name: string
|
|
20
|
-
version: string
|
|
21
|
-
/** Shown to the agent once, when it connects. Static text; nothing that came from the data belongs here. */
|
|
22
|
-
instructions: string
|
|
23
|
-
tools: readonly McpTool[]
|
|
24
|
-
/** The caller behind a `Bearer` token, or null. Cookies are never looked at: a browser must not be able to use this endpoint. */
|
|
25
|
-
authenticate(req: FastifyRequest): Promise<McpCaller | null>
|
|
26
|
-
/** Whether the endpoint answers at all. Asked first on every request: when false it is indistinguishable from a path that does not exist. */
|
|
27
|
-
enabled(): boolean | Promise<boolean>
|
|
28
|
-
/** Whether tools that change data are offered. When false a caller sees only the read-only ones, whatever its token may do. */
|
|
29
|
-
allowChanges(): boolean | Promise<boolean>
|
|
30
|
-
/** The address visitors use; known only when configured. */
|
|
31
|
-
publicUrl(): string | undefined
|
|
32
|
-
/** Options of @fastify/rate-limit for the route. */
|
|
33
|
-
rateLimit?: Record<string, unknown>
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const MAX_BODY_BYTES = 256 * 1024
|
|
37
|
-
|
|
38
|
-
/** The caller's view of the host's API: each call is a request to the same app, so its checks, limits and audit apply unchanged. */
|
|
39
|
-
function contextFor(app: FastifyInstance, caller: McpCaller): McpToolContext {
|
|
40
|
-
return {
|
|
41
|
-
can: (scope) => tokenAllows(caller.scopes, scope),
|
|
42
|
-
get: (path) => request('GET', path),
|
|
43
|
-
send: (method, path, body, contentType) => {
|
|
44
|
-
// The types already say so; this holds when a tool is written loosely.
|
|
45
|
-
if (!['POST', 'PATCH', 'PUT'].includes(method)) throw new McpToolError('A tool tried to send a request that is not allowed')
|
|
46
|
-
return request(method, path, body, contentType)
|
|
47
|
-
},
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function request(method: 'GET' | 'POST' | 'PATCH' | 'PUT', url: string, body?: unknown, bodyType?: string): Promise<unknown> {
|
|
51
|
-
const [path = '', ...query] = url.split('?')
|
|
52
|
-
// A request that changes data has no query: a tool must not be able to reach, say, an apply with a flag that deletes.
|
|
53
|
-
const allowed = ['GET', 'POST', 'PATCH', 'PUT'].includes(method) && SAFE_PATH.test(path) && !path.includes('..') && (method === 'GET' || query.length === 0)
|
|
54
|
-
if (!allowed) throw new McpToolError('A tool tried to send a request that is not allowed')
|
|
55
|
-
const headers: Record<string, string> = { authorization: caller.authorization }
|
|
56
|
-
if (bodyType) headers['content-type'] = bodyType
|
|
57
|
-
const response = await app.inject({
|
|
58
|
-
method, url, headers,
|
|
59
|
-
...(method === 'GET' ? {} : { payload: bodyType ? String(body) : (body as object | undefined) ?? {} }),
|
|
60
|
-
})
|
|
61
|
-
const contentType = String(response.headers['content-type'] ?? '')
|
|
62
|
-
if (response.statusCode >= 400) {
|
|
63
|
-
const failure = contentType.includes('json') ? (response.json() as { error?: unknown; problems?: unknown }) : {}
|
|
64
|
-
const problems = Array.isArray(failure.problems) ? ` ${JSON.stringify(failure.problems).slice(0, 1500)}` : ''
|
|
65
|
-
throw new McpToolError(`${response.statusCode}: ${typeof failure.error === 'string' ? cleanText(failure.error) : 'The request failed'}${cleanText(problems)}`)
|
|
66
|
-
}
|
|
67
|
-
if (response.statusCode === 204) return { ok: true }
|
|
68
|
-
return contentType.includes('json') ? response.json() : response.body
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function annotationsFor(access: McpTool['access']) {
|
|
73
|
-
return access === 'read'
|
|
74
|
-
? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
75
|
-
: { readOnlyHint: false, destructiveHint: access === 'destructive', idempotentHint: false, openWorldHint: false }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function serverFor(app: FastifyInstance, options: McpEndpointOptions, caller: McpCaller, allowChanges: boolean): McpServer {
|
|
79
|
-
const server = new McpServer({ name: options.name, version: options.version }, { instructions: options.instructions })
|
|
80
|
-
const context = contextFor(app, caller)
|
|
81
|
-
for (const tool of options.tools) {
|
|
82
|
-
if (!tokenAllows(caller.scopes, tool.scope)) continue
|
|
83
|
-
if (tool.access !== 'read' && !allowChanges) continue
|
|
84
|
-
server.registerTool(
|
|
85
|
-
tool.name,
|
|
86
|
-
{
|
|
87
|
-
title: tool.title,
|
|
88
|
-
description: tool.description,
|
|
89
|
-
// Strict: an argument that does not exist is an error the agent sees, not a change that silently did not happen.
|
|
90
|
-
inputSchema: z.strictObject(tool.input),
|
|
91
|
-
annotations: annotationsFor(tool.access),
|
|
92
|
-
},
|
|
93
|
-
async (args: unknown) => {
|
|
94
|
-
try {
|
|
95
|
-
const data = await (tool.handler as (args: unknown, ctx: McpToolContext) => Promise<unknown>)(args, context)
|
|
96
|
-
return { content: [{ type: 'text' as const, text: boundedJson(cleanDeep(data)) }] }
|
|
97
|
-
} catch (error) {
|
|
98
|
-
// A refused or failed request is the tool's answer; anything unexpected is reported without its details.
|
|
99
|
-
const text = error instanceof McpToolError ? error.message : 'The tool failed'
|
|
100
|
-
return { isError: true, content: [{ type: 'text' as const, text }] }
|
|
101
|
-
}
|
|
102
|
-
},
|
|
103
|
-
)
|
|
104
|
-
}
|
|
105
|
-
return server
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Registers `POST <path>`: the MCP Streamable HTTP transport in its stateless form (a request carries everything, no
|
|
110
|
-
* session is kept). The route accepts a `Bearer` token only and lists just the tools that token may use.
|
|
111
|
-
*/
|
|
112
|
-
export function registerMcpEndpoint(app: FastifyInstance, options: McpEndpointOptions): void {
|
|
113
|
-
// First of everything, before authentication and before the body is read: an endpoint that is off leaks nothing.
|
|
114
|
-
const gate: onRequestAsyncHookHandler = async (_req, reply) => {
|
|
115
|
-
let on = false
|
|
116
|
-
try { on = await options.enabled() } catch { /* not ready: off */ }
|
|
117
|
-
if (!on) return reply.callNotFound()
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
app.route({
|
|
121
|
-
method: ['GET', 'DELETE'],
|
|
122
|
-
url: options.path,
|
|
123
|
-
onRequest: gate,
|
|
124
|
-
handler: (_req, reply) => reply.code(405).header('allow', 'POST').send({ error: 'Use POST' }),
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
app.post(options.path, { onRequest: gate, bodyLimit: MAX_BODY_BYTES, config: { rateLimit: options.rateLimit } }, async (req, reply) => {
|
|
128
|
-
const problem = crossSiteProblem(req.headers, options.publicUrl())
|
|
129
|
-
if (problem) return reply.code(403).send({ error: problem })
|
|
130
|
-
|
|
131
|
-
// One call per request: a batch would spend one rate-limit hit on up to a hundred calls.
|
|
132
|
-
if (Array.isArray(req.body)) return reply.code(400).send({ error: 'Batched requests are not accepted' })
|
|
133
|
-
|
|
134
|
-
let caller: McpCaller | null = null
|
|
135
|
-
try { caller = await options.authenticate(req) } catch { /* not signed in */ }
|
|
136
|
-
if (!caller) return reply.code(401).header('www-authenticate', 'Bearer realm="mcp"').send({ error: 'Unauthorized' })
|
|
137
|
-
|
|
138
|
-
// Not knowing is not allowing.
|
|
139
|
-
let allowChanges = false
|
|
140
|
-
try { allowChanges = await options.allowChanges() } catch { /* read-only */ }
|
|
141
|
-
const server = serverFor(app, options, caller, allowChanges)
|
|
142
|
-
// Without a session id generator the transport is stateless.
|
|
143
|
-
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true })
|
|
144
|
-
// The transport writes the response itself, so Fastify's own hooks no longer run: set what they would.
|
|
145
|
-
reply.hijack()
|
|
146
|
-
const res = reply.raw
|
|
147
|
-
res.setHeader('x-content-type-options', 'nosniff')
|
|
148
|
-
res.setHeader('cache-control', 'no-store')
|
|
149
|
-
res.on('close', () => { void transport.close(); void server.close() })
|
|
150
|
-
try {
|
|
151
|
-
// The SDK's own transport type does not satisfy exactOptionalPropertyTypes.
|
|
152
|
-
await server.connect(transport as unknown as Parameters<McpServer['connect']>[0])
|
|
153
|
-
await transport.handleRequest(req.raw, res, req.body)
|
|
154
|
-
} catch {
|
|
155
|
-
if (res.headersSent) res.destroy()
|
|
156
|
-
else {
|
|
157
|
-
res.statusCode = 500
|
|
158
|
-
res.setHeader('content-type', 'application/json')
|
|
159
|
-
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null }))
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
})
|
|
163
|
-
}
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
3
|
+
import { tokenAllows } from '@better-forge/contracts'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { FastifyInstance, FastifyRequest, onRequestAsyncHookHandler } from 'fastify'
|
|
6
|
+
import { crossSiteProblem } from './guard.js'
|
|
7
|
+
import { boundedJson, cleanDeep, McpToolError, cleanText } from './output.js'
|
|
8
|
+
import { SAFE_PATH, type McpTool, type McpToolContext } from './tools.js'
|
|
9
|
+
|
|
10
|
+
/** Who is calling: the permissions of the token and the header to hand on to the host's own API. */
|
|
11
|
+
export interface McpCaller {
|
|
12
|
+
authorization: string
|
|
13
|
+
scopes: readonly string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface McpEndpointOptions {
|
|
17
|
+
/** Path of the endpoint. */
|
|
18
|
+
path: string
|
|
19
|
+
name: string
|
|
20
|
+
version: string
|
|
21
|
+
/** Shown to the agent once, when it connects. Static text; nothing that came from the data belongs here. */
|
|
22
|
+
instructions: string
|
|
23
|
+
tools: readonly McpTool[]
|
|
24
|
+
/** The caller behind a `Bearer` token, or null. Cookies are never looked at: a browser must not be able to use this endpoint. */
|
|
25
|
+
authenticate(req: FastifyRequest): Promise<McpCaller | null>
|
|
26
|
+
/** Whether the endpoint answers at all. Asked first on every request: when false it is indistinguishable from a path that does not exist. */
|
|
27
|
+
enabled(): boolean | Promise<boolean>
|
|
28
|
+
/** Whether tools that change data are offered. When false a caller sees only the read-only ones, whatever its token may do. */
|
|
29
|
+
allowChanges(): boolean | Promise<boolean>
|
|
30
|
+
/** The address visitors use; known only when configured. */
|
|
31
|
+
publicUrl(): string | undefined
|
|
32
|
+
/** Options of @fastify/rate-limit for the route. */
|
|
33
|
+
rateLimit?: Record<string, unknown>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MAX_BODY_BYTES = 256 * 1024
|
|
37
|
+
|
|
38
|
+
/** The caller's view of the host's API: each call is a request to the same app, so its checks, limits and audit apply unchanged. */
|
|
39
|
+
function contextFor(app: FastifyInstance, caller: McpCaller): McpToolContext {
|
|
40
|
+
return {
|
|
41
|
+
can: (scope) => tokenAllows(caller.scopes, scope),
|
|
42
|
+
get: (path) => request('GET', path),
|
|
43
|
+
send: (method, path, body, contentType) => {
|
|
44
|
+
// The types already say so; this holds when a tool is written loosely.
|
|
45
|
+
if (!['POST', 'PATCH', 'PUT'].includes(method)) throw new McpToolError('A tool tried to send a request that is not allowed')
|
|
46
|
+
return request(method, path, body, contentType)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function request(method: 'GET' | 'POST' | 'PATCH' | 'PUT', url: string, body?: unknown, bodyType?: string): Promise<unknown> {
|
|
51
|
+
const [path = '', ...query] = url.split('?')
|
|
52
|
+
// A request that changes data has no query: a tool must not be able to reach, say, an apply with a flag that deletes.
|
|
53
|
+
const allowed = ['GET', 'POST', 'PATCH', 'PUT'].includes(method) && SAFE_PATH.test(path) && !path.includes('..') && (method === 'GET' || query.length === 0)
|
|
54
|
+
if (!allowed) throw new McpToolError('A tool tried to send a request that is not allowed')
|
|
55
|
+
const headers: Record<string, string> = { authorization: caller.authorization }
|
|
56
|
+
if (bodyType) headers['content-type'] = bodyType
|
|
57
|
+
const response = await app.inject({
|
|
58
|
+
method, url, headers,
|
|
59
|
+
...(method === 'GET' ? {} : { payload: bodyType ? String(body) : (body as object | undefined) ?? {} }),
|
|
60
|
+
})
|
|
61
|
+
const contentType = String(response.headers['content-type'] ?? '')
|
|
62
|
+
if (response.statusCode >= 400) {
|
|
63
|
+
const failure = contentType.includes('json') ? (response.json() as { error?: unknown; problems?: unknown }) : {}
|
|
64
|
+
const problems = Array.isArray(failure.problems) ? ` ${JSON.stringify(failure.problems).slice(0, 1500)}` : ''
|
|
65
|
+
throw new McpToolError(`${response.statusCode}: ${typeof failure.error === 'string' ? cleanText(failure.error) : 'The request failed'}${cleanText(problems)}`)
|
|
66
|
+
}
|
|
67
|
+
if (response.statusCode === 204) return { ok: true }
|
|
68
|
+
return contentType.includes('json') ? response.json() : response.body
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function annotationsFor(access: McpTool['access']) {
|
|
73
|
+
return access === 'read'
|
|
74
|
+
? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
75
|
+
: { readOnlyHint: false, destructiveHint: access === 'destructive', idempotentHint: false, openWorldHint: false }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function serverFor(app: FastifyInstance, options: McpEndpointOptions, caller: McpCaller, allowChanges: boolean): McpServer {
|
|
79
|
+
const server = new McpServer({ name: options.name, version: options.version }, { instructions: options.instructions })
|
|
80
|
+
const context = contextFor(app, caller)
|
|
81
|
+
for (const tool of options.tools) {
|
|
82
|
+
if (!tokenAllows(caller.scopes, tool.scope)) continue
|
|
83
|
+
if (tool.access !== 'read' && !allowChanges) continue
|
|
84
|
+
server.registerTool(
|
|
85
|
+
tool.name,
|
|
86
|
+
{
|
|
87
|
+
title: tool.title,
|
|
88
|
+
description: tool.description,
|
|
89
|
+
// Strict: an argument that does not exist is an error the agent sees, not a change that silently did not happen.
|
|
90
|
+
inputSchema: z.strictObject(tool.input),
|
|
91
|
+
annotations: annotationsFor(tool.access),
|
|
92
|
+
},
|
|
93
|
+
async (args: unknown) => {
|
|
94
|
+
try {
|
|
95
|
+
const data = await (tool.handler as (args: unknown, ctx: McpToolContext) => Promise<unknown>)(args, context)
|
|
96
|
+
return { content: [{ type: 'text' as const, text: boundedJson(cleanDeep(data)) }] }
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// A refused or failed request is the tool's answer; anything unexpected is reported without its details.
|
|
99
|
+
const text = error instanceof McpToolError ? error.message : 'The tool failed'
|
|
100
|
+
return { isError: true, content: [{ type: 'text' as const, text }] }
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
return server
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Registers `POST <path>`: the MCP Streamable HTTP transport in its stateless form (a request carries everything, no
|
|
110
|
+
* session is kept). The route accepts a `Bearer` token only and lists just the tools that token may use.
|
|
111
|
+
*/
|
|
112
|
+
export function registerMcpEndpoint(app: FastifyInstance, options: McpEndpointOptions): void {
|
|
113
|
+
// First of everything, before authentication and before the body is read: an endpoint that is off leaks nothing.
|
|
114
|
+
const gate: onRequestAsyncHookHandler = async (_req, reply) => {
|
|
115
|
+
let on = false
|
|
116
|
+
try { on = await options.enabled() } catch { /* not ready: off */ }
|
|
117
|
+
if (!on) return reply.callNotFound()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
app.route({
|
|
121
|
+
method: ['GET', 'DELETE'],
|
|
122
|
+
url: options.path,
|
|
123
|
+
onRequest: gate,
|
|
124
|
+
handler: (_req, reply) => reply.code(405).header('allow', 'POST').send({ error: 'Use POST' }),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
app.post(options.path, { onRequest: gate, bodyLimit: MAX_BODY_BYTES, config: { rateLimit: options.rateLimit } }, async (req, reply) => {
|
|
128
|
+
const problem = crossSiteProblem(req.headers, options.publicUrl())
|
|
129
|
+
if (problem) return reply.code(403).send({ error: problem })
|
|
130
|
+
|
|
131
|
+
// One call per request: a batch would spend one rate-limit hit on up to a hundred calls.
|
|
132
|
+
if (Array.isArray(req.body)) return reply.code(400).send({ error: 'Batched requests are not accepted' })
|
|
133
|
+
|
|
134
|
+
let caller: McpCaller | null = null
|
|
135
|
+
try { caller = await options.authenticate(req) } catch { /* not signed in */ }
|
|
136
|
+
if (!caller) return reply.code(401).header('www-authenticate', 'Bearer realm="mcp"').send({ error: 'Unauthorized' })
|
|
137
|
+
|
|
138
|
+
// Not knowing is not allowing.
|
|
139
|
+
let allowChanges = false
|
|
140
|
+
try { allowChanges = await options.allowChanges() } catch { /* read-only */ }
|
|
141
|
+
const server = serverFor(app, options, caller, allowChanges)
|
|
142
|
+
// Without a session id generator the transport is stateless.
|
|
143
|
+
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true })
|
|
144
|
+
// The transport writes the response itself, so Fastify's own hooks no longer run: set what they would.
|
|
145
|
+
reply.hijack()
|
|
146
|
+
const res = reply.raw
|
|
147
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
148
|
+
res.setHeader('cache-control', 'no-store')
|
|
149
|
+
res.on('close', () => { void transport.close(); void server.close() })
|
|
150
|
+
try {
|
|
151
|
+
// The SDK's own transport type does not satisfy exactOptionalPropertyTypes.
|
|
152
|
+
await server.connect(transport as unknown as Parameters<McpServer['connect']>[0])
|
|
153
|
+
await transport.handleRequest(req.raw, res, req.body)
|
|
154
|
+
} catch {
|
|
155
|
+
if (res.headersSent) res.destroy()
|
|
156
|
+
else {
|
|
157
|
+
res.statusCode = 500
|
|
158
|
+
res.setHeader('content-type', 'application/json')
|
|
159
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null }))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
}
|
package/src/guard.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type { IncomingHttpHeaders } from 'node:http'
|
|
2
|
-
|
|
3
|
-
function parse(publicUrl: string | undefined): URL | null {
|
|
4
|
-
if (!publicUrl) return null
|
|
5
|
-
try { return new URL(publicUrl) } catch { return null }
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Why a request must not reach the endpoint, or null. Clients of this endpoint (CLI tools, desktop apps) send no
|
|
10
|
-
* `Origin`; a browser always does, so any foreign origin is refused whatever the method. With a public URL known the
|
|
11
|
-
* `Host` header must be that host too, so a name that resolves to this server by DNS rebinding gets nothing.
|
|
12
|
-
*/
|
|
13
|
-
export function crossSiteProblem(headers: IncomingHttpHeaders, publicUrl: string | undefined): string | null {
|
|
14
|
-
const own = parse(publicUrl)
|
|
15
|
-
const origin = headers.origin
|
|
16
|
-
if (origin !== undefined && origin !== own?.origin) return 'Requests from a web page are not accepted'
|
|
17
|
-
if (own && headers.host?.toLowerCase() !== own.host.toLowerCase()) return 'The Host header does not match the public URL'
|
|
18
|
-
return null
|
|
19
|
-
}
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http'
|
|
2
|
+
|
|
3
|
+
function parse(publicUrl: string | undefined): URL | null {
|
|
4
|
+
if (!publicUrl) return null
|
|
5
|
+
try { return new URL(publicUrl) } catch { return null }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why a request must not reach the endpoint, or null. Clients of this endpoint (CLI tools, desktop apps) send no
|
|
10
|
+
* `Origin`; a browser always does, so any foreign origin is refused whatever the method. With a public URL known the
|
|
11
|
+
* `Host` header must be that host too, so a name that resolves to this server by DNS rebinding gets nothing.
|
|
12
|
+
*/
|
|
13
|
+
export function crossSiteProblem(headers: IncomingHttpHeaders, publicUrl: string | undefined): string | null {
|
|
14
|
+
const own = parse(publicUrl)
|
|
15
|
+
const origin = headers.origin
|
|
16
|
+
if (origin !== undefined && origin !== own?.origin) return 'Requests from a web page are not accepted'
|
|
17
|
+
if (own && headers.host?.toLowerCase() !== own.host.toLowerCase()) return 'The Host header does not match the public URL'
|
|
18
|
+
return null
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { registerMcpEndpoint } from './endpoint.js'
|
|
2
|
-
export type { McpCaller, McpEndpointOptions } from './endpoint.js'
|
|
3
|
-
export { crossSiteProblem } from './guard.js'
|
|
4
|
-
export { boundedJson, cleanDeep, cleanText, McpToolError } from './output.js'
|
|
5
|
-
export { defineTool, query } from './tools.js'
|
|
6
|
-
export type { McpTool, McpToolContext, McpToolDefinition } from './tools.js'
|
|
1
|
+
export { registerMcpEndpoint } from './endpoint.js'
|
|
2
|
+
export type { McpCaller, McpEndpointOptions } from './endpoint.js'
|
|
3
|
+
export { crossSiteProblem } from './guard.js'
|
|
4
|
+
export { boundedJson, cleanDeep, cleanText, McpToolError } from './output.js'
|
|
5
|
+
export { defineTool, query } from './tools.js'
|
|
6
|
+
export type { McpTool, McpToolContext, McpToolDefinition } from './tools.js'
|
package/src/output.ts
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
/** Text that came from outside the instance (a monitored system, a visitor, an import) must reach an agent as plain data. */
|
|
2
|
-
const MAX_TEXT_LENGTH = 2000
|
|
3
|
-
const MAX_DEPTH = 12
|
|
4
|
-
const MAX_OUTPUT_BYTES = 64 * 1024
|
|
5
|
-
|
|
6
|
-
/** An escape sequence that sets colours or moves the cursor. */
|
|
7
|
-
const ANSI = new RegExp(String.raw`${String.fromCharCode(27)}\[[0-9;?]*[ -/]*[@-~]`, 'g')
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Control characters (tab and line breaks stay), bidi controls, zero-width characters, the byte order mark, variation
|
|
11
|
-
* selectors, fillers and the Unicode tag characters, which people cannot see but a model reads.
|
|
12
|
-
*/
|
|
13
|
-
const INVISIBLE: ReadonlyArray<readonly [number, number]> = [
|
|
14
|
-
[0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f],
|
|
15
|
-
[0x00ad, 0x00ad], [0x061c, 0x061c], [0x115f, 0x1160], [0x180e, 0x180e],
|
|
16
|
-
[0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x2069], [0x3164, 0x3164],
|
|
17
|
-
[0xfe00, 0xfe0f], [0xfeff, 0xfeff], [0xe0000, 0xe007f], [0xe0100, 0xe01ef],
|
|
18
|
-
]
|
|
19
|
-
|
|
20
|
-
const isInvisible = (code: number): boolean => INVISIBLE.some(([from, to]) => code >= from && code <= to)
|
|
21
|
-
|
|
22
|
-
export function cleanText(value: string): string {
|
|
23
|
-
const clean = Array.from(value.replace(ANSI, '')).filter((char) => !isInvisible(char.codePointAt(0)!)).join('')
|
|
24
|
-
return clean.length > MAX_TEXT_LENGTH ? `${clean.slice(0, MAX_TEXT_LENGTH)}…` : clean
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Every string in a result, cleaned. */
|
|
28
|
-
export function cleanDeep(value: unknown, depth = 0): unknown {
|
|
29
|
-
if (typeof value === 'string') return cleanText(value)
|
|
30
|
-
if (value === null || typeof value !== 'object') return value
|
|
31
|
-
// Nothing below the limit is passed on unchecked.
|
|
32
|
-
if (depth >= MAX_DEPTH) return '[nested too deep]'
|
|
33
|
-
if (Array.isArray(value)) return value.map((each) => cleanDeep(each, depth + 1))
|
|
34
|
-
return Object.fromEntries(Object.entries(value).map(([key, each]) => [cleanText(key), cleanDeep(each, depth + 1)]))
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** A failure the agent should read as the tool's answer rather than as a crash. */
|
|
38
|
-
export class McpToolError extends Error {}
|
|
39
|
-
|
|
40
|
-
/** A tool result as text. A list under `items` is shortened until it fits; anything else that is too large is refused. */
|
|
41
|
-
export function boundedJson(data: unknown): string {
|
|
42
|
-
const text = JSON.stringify(data)
|
|
43
|
-
if (Buffer.byteLength(text) <= MAX_OUTPUT_BYTES) return text
|
|
44
|
-
const holder = data as { items?: unknown[] } | null
|
|
45
|
-
if (holder && typeof holder === 'object' && Array.isArray(holder.items)) {
|
|
46
|
-
let keep = holder.items.length
|
|
47
|
-
while (keep > 0) {
|
|
48
|
-
keep = Math.floor(keep / 2)
|
|
49
|
-
const shortened = JSON.stringify({ ...holder, items: holder.items.slice(0, keep), truncated: true, returnedItems: keep })
|
|
50
|
-
if (Buffer.byteLength(shortened) <= MAX_OUTPUT_BYTES) return shortened
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
throw new McpToolError('The result is too large to return. Narrow the query (a shorter period, a smaller limit, a filter).')
|
|
54
|
-
}
|
|
1
|
+
/** Text that came from outside the instance (a monitored system, a visitor, an import) must reach an agent as plain data. */
|
|
2
|
+
const MAX_TEXT_LENGTH = 2000
|
|
3
|
+
const MAX_DEPTH = 12
|
|
4
|
+
const MAX_OUTPUT_BYTES = 64 * 1024
|
|
5
|
+
|
|
6
|
+
/** An escape sequence that sets colours or moves the cursor. */
|
|
7
|
+
const ANSI = new RegExp(String.raw`${String.fromCharCode(27)}\[[0-9;?]*[ -/]*[@-~]`, 'g')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Control characters (tab and line breaks stay), bidi controls, zero-width characters, the byte order mark, variation
|
|
11
|
+
* selectors, fillers and the Unicode tag characters, which people cannot see but a model reads.
|
|
12
|
+
*/
|
|
13
|
+
const INVISIBLE: ReadonlyArray<readonly [number, number]> = [
|
|
14
|
+
[0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f],
|
|
15
|
+
[0x00ad, 0x00ad], [0x061c, 0x061c], [0x115f, 0x1160], [0x180e, 0x180e],
|
|
16
|
+
[0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x2069], [0x3164, 0x3164],
|
|
17
|
+
[0xfe00, 0xfe0f], [0xfeff, 0xfeff], [0xe0000, 0xe007f], [0xe0100, 0xe01ef],
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const isInvisible = (code: number): boolean => INVISIBLE.some(([from, to]) => code >= from && code <= to)
|
|
21
|
+
|
|
22
|
+
export function cleanText(value: string): string {
|
|
23
|
+
const clean = Array.from(value.replace(ANSI, '')).filter((char) => !isInvisible(char.codePointAt(0)!)).join('')
|
|
24
|
+
return clean.length > MAX_TEXT_LENGTH ? `${clean.slice(0, MAX_TEXT_LENGTH)}…` : clean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Every string in a result, cleaned. */
|
|
28
|
+
export function cleanDeep(value: unknown, depth = 0): unknown {
|
|
29
|
+
if (typeof value === 'string') return cleanText(value)
|
|
30
|
+
if (value === null || typeof value !== 'object') return value
|
|
31
|
+
// Nothing below the limit is passed on unchecked.
|
|
32
|
+
if (depth >= MAX_DEPTH) return '[nested too deep]'
|
|
33
|
+
if (Array.isArray(value)) return value.map((each) => cleanDeep(each, depth + 1))
|
|
34
|
+
return Object.fromEntries(Object.entries(value).map(([key, each]) => [cleanText(key), cleanDeep(each, depth + 1)]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A failure the agent should read as the tool's answer rather than as a crash. */
|
|
38
|
+
export class McpToolError extends Error {}
|
|
39
|
+
|
|
40
|
+
/** A tool result as text. A list under `items` is shortened until it fits; anything else that is too large is refused. */
|
|
41
|
+
export function boundedJson(data: unknown): string {
|
|
42
|
+
const text = JSON.stringify(data)
|
|
43
|
+
if (Buffer.byteLength(text) <= MAX_OUTPUT_BYTES) return text
|
|
44
|
+
const holder = data as { items?: unknown[] } | null
|
|
45
|
+
if (holder && typeof holder === 'object' && Array.isArray(holder.items)) {
|
|
46
|
+
let keep = holder.items.length
|
|
47
|
+
while (keep > 0) {
|
|
48
|
+
keep = Math.floor(keep / 2)
|
|
49
|
+
const shortened = JSON.stringify({ ...holder, items: holder.items.slice(0, keep), truncated: true, returnedItems: keep })
|
|
50
|
+
if (Buffer.byteLength(shortened) <= MAX_OUTPUT_BYTES) return shortened
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new McpToolError('The result is too large to return. Narrow the query (a shorter period, a smaller limit, a filter).')
|
|
54
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
import type { z } from 'zod'
|
|
2
|
-
|
|
3
|
-
/** What a tool may do with the host application: read its own API with the credentials of the caller. */
|
|
4
|
-
export interface McpToolContext {
|
|
5
|
-
/** True when the caller's token holds the permission. */
|
|
6
|
-
can(scope: string): boolean
|
|
7
|
-
/** GET a path of the host application's API (`/api/v1/admin/monitors`); JSON comes back parsed, anything else as text. */
|
|
8
|
-
get(path: string): Promise<unknown>
|
|
9
|
-
/** POST, PATCH or PUT a body (JSON, or text when `contentType` is given) to a path of the host's API. Nothing else can be sent: no DELETE. */
|
|
10
|
-
send(method: 'POST' | 'PATCH' | 'PUT', path: string, body: unknown, contentType?: string): Promise<unknown>
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* What a tool does to the data: `read` changes nothing; `write` changes something that can be changed back or that
|
|
15
|
-
* nobody sees yet; `destructive` is visible to other people or cannot be undone (an agent client asks first).
|
|
16
|
-
*/
|
|
17
|
-
export type McpAccess = 'read' | 'write' | 'destructive'
|
|
18
|
-
|
|
19
|
-
/** Where a tool may send a request: a path of the host, nothing that could leave it or change its meaning. */
|
|
20
|
-
export const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9_\-./]*$/
|
|
21
|
-
|
|
22
|
-
export interface McpTool {
|
|
23
|
-
name: string
|
|
24
|
-
title: string
|
|
25
|
-
description: string
|
|
26
|
-
/** The permission a token needs to see and call the tool. */
|
|
27
|
-
scope: string
|
|
28
|
-
access: McpAccess
|
|
29
|
-
input: z.ZodRawShape
|
|
30
|
-
handler(args: never, ctx: McpToolContext): Promise<unknown>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface McpToolDefinition<Shape extends z.ZodRawShape> {
|
|
34
|
-
name: string
|
|
35
|
-
title: string
|
|
36
|
-
description: string
|
|
37
|
-
scope: string
|
|
38
|
-
/** Defaults to `read`. */
|
|
39
|
-
access?: McpAccess
|
|
40
|
-
input: Shape
|
|
41
|
-
handler(args: z.infer<z.ZodObject<Shape>>, ctx: McpToolContext): Promise<unknown>
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function defineTool<Shape extends z.ZodRawShape>(definition: McpToolDefinition<Shape>): McpTool {
|
|
45
|
-
return { access: 'read', ...definition } as unknown as McpTool
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Query string from the values that were given. */
|
|
49
|
-
export function query(values: Record<string, string | number | boolean | undefined>): string {
|
|
50
|
-
const params = new URLSearchParams()
|
|
51
|
-
for (const [key, value] of Object.entries(values)) if (value !== undefined) params.set(key, String(value))
|
|
52
|
-
const text = params.toString()
|
|
53
|
-
return text ? `?${text}` : ''
|
|
54
|
-
}
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** What a tool may do with the host application: read its own API with the credentials of the caller. */
|
|
4
|
+
export interface McpToolContext {
|
|
5
|
+
/** True when the caller's token holds the permission. */
|
|
6
|
+
can(scope: string): boolean
|
|
7
|
+
/** GET a path of the host application's API (`/api/v1/admin/monitors`); JSON comes back parsed, anything else as text. */
|
|
8
|
+
get(path: string): Promise<unknown>
|
|
9
|
+
/** POST, PATCH or PUT a body (JSON, or text when `contentType` is given) to a path of the host's API. Nothing else can be sent: no DELETE. */
|
|
10
|
+
send(method: 'POST' | 'PATCH' | 'PUT', path: string, body: unknown, contentType?: string): Promise<unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a tool does to the data: `read` changes nothing; `write` changes something that can be changed back or that
|
|
15
|
+
* nobody sees yet; `destructive` is visible to other people or cannot be undone (an agent client asks first).
|
|
16
|
+
*/
|
|
17
|
+
export type McpAccess = 'read' | 'write' | 'destructive'
|
|
18
|
+
|
|
19
|
+
/** Where a tool may send a request: a path of the host, nothing that could leave it or change its meaning. */
|
|
20
|
+
export const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9_\-./]*$/
|
|
21
|
+
|
|
22
|
+
export interface McpTool {
|
|
23
|
+
name: string
|
|
24
|
+
title: string
|
|
25
|
+
description: string
|
|
26
|
+
/** The permission a token needs to see and call the tool. */
|
|
27
|
+
scope: string
|
|
28
|
+
access: McpAccess
|
|
29
|
+
input: z.ZodRawShape
|
|
30
|
+
handler(args: never, ctx: McpToolContext): Promise<unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface McpToolDefinition<Shape extends z.ZodRawShape> {
|
|
34
|
+
name: string
|
|
35
|
+
title: string
|
|
36
|
+
description: string
|
|
37
|
+
scope: string
|
|
38
|
+
/** Defaults to `read`. */
|
|
39
|
+
access?: McpAccess
|
|
40
|
+
input: Shape
|
|
41
|
+
handler(args: z.infer<z.ZodObject<Shape>>, ctx: McpToolContext): Promise<unknown>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function defineTool<Shape extends z.ZodRawShape>(definition: McpToolDefinition<Shape>): McpTool {
|
|
45
|
+
return { access: 'read', ...definition } as unknown as McpTool
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Query string from the values that were given. */
|
|
49
|
+
export function query(values: Record<string, string | number | boolean | undefined>): string {
|
|
50
|
+
const params = new URLSearchParams()
|
|
51
|
+
for (const [key, value] of Object.entries(values)) if (value !== undefined) params.set(key, String(value))
|
|
52
|
+
const text = params.toString()
|
|
53
|
+
return text ? `?${text}` : ''
|
|
54
|
+
}
|