@absolutejs/mcp 0.0.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 +89 -0
- package/README.md +132 -0
- package/dist/index.js +258 -0
- package/dist/src/auth.d.ts +26 -0
- package/dist/src/dispatch.d.ts +4 -0
- package/dist/src/guards.d.ts +1 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/jsonrpc.d.ts +15 -0
- package/dist/src/metadata.d.ts +13 -0
- package/dist/src/server.d.ts +11 -0
- package/dist/src/types.d.ts +123 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
**Licensor:** Alex Kahn
|
|
4
|
+
|
|
5
|
+
**Licensed Work:** @absolutejs/mcp (https://github.com/absolutejs/mcp)
|
|
6
|
+
|
|
7
|
+
**Change Date:** July 10, 2030
|
|
8
|
+
|
|
9
|
+
**Change License:** Apache License, Version 2.0
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Terms
|
|
14
|
+
|
|
15
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
16
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
17
|
+
Licensor may make an Additional Use Grant, permitting limited production use.
|
|
18
|
+
|
|
19
|
+
### Additional Use Grant
|
|
20
|
+
|
|
21
|
+
You may use the Licensed Work in production, provided your use does not include
|
|
22
|
+
any of the following:
|
|
23
|
+
|
|
24
|
+
1. **Offering a Competing Service.** You may not offer the Licensed Work, or
|
|
25
|
+
any derivative or substantial portion of it, to third parties as a hosted or
|
|
26
|
+
managed Model Context Protocol (MCP) gateway, registry, proxy, or
|
|
27
|
+
server-hosting service (including, but not limited to, services like
|
|
28
|
+
Smithery, Composio, or mcp.run). This includes any product whose primary
|
|
29
|
+
value to its users is the functionality the Licensed Work provides.
|
|
30
|
+
|
|
31
|
+
2. **Resale or Redistribution as a Standalone Product.** You may not sell,
|
|
32
|
+
license, or distribute the Licensed Work, or any derivative or fork of it,
|
|
33
|
+
as a standalone commercial product.
|
|
34
|
+
|
|
35
|
+
3. **Removal of Attribution.** Any derivative work, fork, or redistribution of
|
|
36
|
+
the Licensed Work must prominently credit AbsoluteJS and include a link to
|
|
37
|
+
the original project repository (https://github.com/absolutejs/mcp).
|
|
38
|
+
|
|
39
|
+
For clarity, the following uses are expressly permitted:
|
|
40
|
+
|
|
41
|
+
- Using the Licensed Work to build and operate your own applications, websites,
|
|
42
|
+
internal tools, or SaaS products (whether commercial or non-commercial), so
|
|
43
|
+
long as the Licensed Work itself is not the primary product you are selling.
|
|
44
|
+
- Using the Licensed Work as a dependency in commercial software you build and
|
|
45
|
+
sell, as long as the software is not itself a competing managed service of
|
|
46
|
+
the kind described in clause 1.
|
|
47
|
+
- Providing consulting, development, or professional services to clients using
|
|
48
|
+
the Licensed Work.
|
|
49
|
+
- Forking and modifying the Licensed Work for your own internal use, provided
|
|
50
|
+
attribution is maintained.
|
|
51
|
+
|
|
52
|
+
### Change Date and Change License
|
|
53
|
+
|
|
54
|
+
On the Change Date specified above, or on such other date as the Licensor may
|
|
55
|
+
specify by written notice, the Licensed Work will be made available under the
|
|
56
|
+
Change License (Apache License, Version 2.0). Until the Change Date, the terms
|
|
57
|
+
of this Business Source License 1.1 apply.
|
|
58
|
+
|
|
59
|
+
### Trademark
|
|
60
|
+
|
|
61
|
+
This license does not grant you any rights to use the "AbsoluteJS" or
|
|
62
|
+
"@absolutejs" name, logo, or any related trademarks. Forks and derivative works
|
|
63
|
+
must not be named or branded in a manner that suggests endorsement by or
|
|
64
|
+
affiliation with AbsoluteJS or the Licensor.
|
|
65
|
+
|
|
66
|
+
### Notices
|
|
67
|
+
|
|
68
|
+
You must not remove or obscure any licensing, copyright, or other notices
|
|
69
|
+
included in the Licensed Work.
|
|
70
|
+
|
|
71
|
+
### No Warranty
|
|
72
|
+
|
|
73
|
+
THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS ALL
|
|
74
|
+
WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
|
75
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO
|
|
76
|
+
EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
|
|
77
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
|
|
78
|
+
IN CONNECTION WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE
|
|
79
|
+
LICENSED WORK.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Contact
|
|
84
|
+
|
|
85
|
+
For commercial licensing inquiries or additional permissions, contact:
|
|
86
|
+
|
|
87
|
+
- **Alex Kahn**
|
|
88
|
+
- alexkahndev@gmail.com
|
|
89
|
+
- alexkahndev.github.io
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @absolutejs/mcp
|
|
2
|
+
|
|
3
|
+
Serve a remote [Model Context Protocol](https://modelcontextprotocol.io) endpoint
|
|
4
|
+
— streamable HTTP, stateless — from a tool/prompt/resource registry. You supply
|
|
5
|
+
**which** tools to expose and **how** to authorize a request into a caller; the
|
|
6
|
+
package owns the JSON-RPC protocol, protocol-version negotiation, RFC 9728
|
|
7
|
+
discovery metadata, and the `401` challenge that lets a client find your
|
|
8
|
+
authorization server.
|
|
9
|
+
|
|
10
|
+
Nothing here depends on a model. The tool shape is structurally compatible with
|
|
11
|
+
[`@absolutejs/ai`](https://github.com/absolutejs/ai)'s `AIToolMap`, so an AI tool
|
|
12
|
+
registry serves over MCP without conversion — but any typed tool registry works.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
bun add @absolutejs/mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Peer dependency: `elysia`.
|
|
19
|
+
|
|
20
|
+
## Define an endpoint
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Elysia } from "elysia";
|
|
24
|
+
import { mcpServer, verifyBearer } from "@absolutejs/mcp";
|
|
25
|
+
import { verifyJwt } from "@absolutejs/auth"; // or any JWT verifier
|
|
26
|
+
|
|
27
|
+
type Caller = { userId: string };
|
|
28
|
+
|
|
29
|
+
const server = new Elysia().use(
|
|
30
|
+
mcpServer<Caller>({
|
|
31
|
+
path: "/mcp",
|
|
32
|
+
issuer: "https://your.app",
|
|
33
|
+
serverInfo: { name: "your-app", title: "Your App", version: "1.0.0" },
|
|
34
|
+
instructions: "What the model should know about this server.",
|
|
35
|
+
scopesSupported: ["openid", "mcp"],
|
|
36
|
+
serveRootMetadata: true,
|
|
37
|
+
|
|
38
|
+
// You decide who is allowed in. verifyBearer does the standard OAuth
|
|
39
|
+
// access-token checks; add your own (billing, role, MFA) on top.
|
|
40
|
+
authorize: async (request) => {
|
|
41
|
+
const token = await verifyBearer({
|
|
42
|
+
request,
|
|
43
|
+
issuer: "https://your.app",
|
|
44
|
+
requiredScope: "mcp",
|
|
45
|
+
verify: (jwt) => verifyJwt(jwt, publicJwk),
|
|
46
|
+
});
|
|
47
|
+
if ("error" in token) return { ok: false, reason: token.error };
|
|
48
|
+
return { ok: true, caller: { userId: token.subject } };
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// Called once per request; build the tools for this caller.
|
|
52
|
+
tools: ({ caller }) => buildToolsFor(caller.userId),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That is a complete member endpoint. `GET /mcp` returns `405`, `POST /mcp` speaks
|
|
58
|
+
JSON-RPC, and `GET /.well-known/oauth-protected-resource[/mcp]` serves the
|
|
59
|
+
discovery metadata.
|
|
60
|
+
|
|
61
|
+
## Guards, prompts, resources
|
|
62
|
+
|
|
63
|
+
Everything beyond tools is a hook — the package ships no opinion about billing,
|
|
64
|
+
storage, or auditing.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
mcpServer<Caller>({
|
|
68
|
+
// ...as above
|
|
69
|
+
|
|
70
|
+
// Refuse a single call before it runs (credits, rate limit). The message
|
|
71
|
+
// comes back as an isError tool result the model can relay — not a crash.
|
|
72
|
+
beforeCall: async ({ caller }) =>
|
|
73
|
+
(await outOfCredits(caller))
|
|
74
|
+
? { block: "Out of credits this cycle." }
|
|
75
|
+
: undefined,
|
|
76
|
+
|
|
77
|
+
// Audit every call. `meta` carries whatever the tool handler wrote.
|
|
78
|
+
onCall: ({ caller, name, ok, meta }) =>
|
|
79
|
+
recordCall({ caller, name, ok, touched: meta.touched }),
|
|
80
|
+
|
|
81
|
+
// Server-side prompts: recipes the client shows in its picker.
|
|
82
|
+
prompts: {
|
|
83
|
+
definitions: {
|
|
84
|
+
daily_briefing: { title: "Daily briefing", description: "..." },
|
|
85
|
+
},
|
|
86
|
+
get: async ({ name, args, caller }) => buildPromptText(name, args, caller),
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Readable resources.
|
|
90
|
+
resources: {
|
|
91
|
+
list: ({ caller }) => listResources(caller),
|
|
92
|
+
read: ({ caller, uri }) => readResource(caller, uri), // string | null
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### The `meta` scratchpad
|
|
98
|
+
|
|
99
|
+
Each `tools/call` gets a fresh `meta` object shared between `tools`,
|
|
100
|
+
`beforeCall`, and `onCall`. A tool handler can record what it touched, and your
|
|
101
|
+
audit hook can read it back:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
tools: ({ caller, meta }) =>
|
|
105
|
+
buildAdminTools(caller, (memberId) => { meta.touched = memberId; }),
|
|
106
|
+
onCall: ({ meta, name, ok }) =>
|
|
107
|
+
ledger.write({ tool: name, ok, member: meta.touched }),
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## A second, stricter endpoint
|
|
111
|
+
|
|
112
|
+
`mcpServer` is per-endpoint, so an admin console is the same call with a
|
|
113
|
+
different scope, a stricter `authorize` (role + MFA + a kill switch, re-checked
|
|
114
|
+
live), a rate-limit `beforeCall`, and an audit `onCall`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
app
|
|
118
|
+
.use(mcpServer({ path: "/mcp" /* member */ }))
|
|
119
|
+
.use(
|
|
120
|
+
mcpServer({
|
|
121
|
+
path: "/mcp/admin",
|
|
122
|
+
scopesSupported: ["openid", "mcp:admin"] /* stricter */,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Only one endpoint per app should set `serveRootMetadata` (the un-suffixed alias).
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
Business Source License 1.1 — see [LICENSE](./LICENSE). Converts to Apache 2.0
|
|
132
|
+
on the Change Date.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/guards.ts
|
|
3
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
|
|
5
|
+
// src/auth.ts
|
|
6
|
+
var MS_PER_SECOND = 1000;
|
|
7
|
+
var verifyBearer = async (config) => {
|
|
8
|
+
const { issuer, request, verify, requiredScope } = config;
|
|
9
|
+
const header = request.headers.get("authorization");
|
|
10
|
+
if (!header?.startsWith("Bearer "))
|
|
11
|
+
return { error: "Missing bearer token" };
|
|
12
|
+
const verified = await verify(header.slice("Bearer ".length));
|
|
13
|
+
if (!verified || !isRecord(verified.payload))
|
|
14
|
+
return { error: "Invalid token" };
|
|
15
|
+
const { payload } = verified;
|
|
16
|
+
if (payload.token_use !== "access")
|
|
17
|
+
return { error: "Not an access token" };
|
|
18
|
+
if (payload.iss !== issuer)
|
|
19
|
+
return { error: "Wrong issuer" };
|
|
20
|
+
const expires = typeof payload.exp === "number" ? payload.exp : 0;
|
|
21
|
+
if (expires * MS_PER_SECOND <= Date.now())
|
|
22
|
+
return { error: "Token expired" };
|
|
23
|
+
const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
|
|
24
|
+
if (requiredScope !== undefined && !scopes.includes(requiredScope)) {
|
|
25
|
+
return { error: `Token lacks the ${requiredScope} scope` };
|
|
26
|
+
}
|
|
27
|
+
const subject = typeof payload.sub === "string" ? payload.sub : "";
|
|
28
|
+
if (!subject)
|
|
29
|
+
return { error: "Token has no subject" };
|
|
30
|
+
return { payload, scopes, subject };
|
|
31
|
+
};
|
|
32
|
+
// src/jsonrpc.ts
|
|
33
|
+
var JSONRPC_PARSE_ERROR = -32700;
|
|
34
|
+
var JSONRPC_INVALID_REQUEST = -32600;
|
|
35
|
+
var JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
36
|
+
var JSONRPC_INVALID_PARAMS = -32602;
|
|
37
|
+
var JSONRPC_INTERNAL_ERROR = -32603;
|
|
38
|
+
var HTTP_ACCEPTED = 202;
|
|
39
|
+
var HTTP_UNAUTHORIZED = 401;
|
|
40
|
+
var HTTP_METHOD_NOT_ALLOWED = 405;
|
|
41
|
+
var jsonHeaders = {
|
|
42
|
+
"content-type": "application/json"
|
|
43
|
+
};
|
|
44
|
+
var rpcResult = (id, result) => new Response(JSON.stringify({ id, jsonrpc: "2.0", result }), {
|
|
45
|
+
headers: jsonHeaders
|
|
46
|
+
});
|
|
47
|
+
var rpcError = (id, code, message) => new Response(JSON.stringify({ error: { code, message }, id, jsonrpc: "2.0" }), {
|
|
48
|
+
headers: jsonHeaders
|
|
49
|
+
});
|
|
50
|
+
var notificationAck = () => new Response(null, { status: HTTP_ACCEPTED });
|
|
51
|
+
var unauthorized = (metadataUrl, detail) => new Response(JSON.stringify({
|
|
52
|
+
error: { code: JSONRPC_INVALID_REQUEST, message: detail },
|
|
53
|
+
id: null,
|
|
54
|
+
jsonrpc: "2.0"
|
|
55
|
+
}), {
|
|
56
|
+
headers: {
|
|
57
|
+
...jsonHeaders,
|
|
58
|
+
"www-authenticate": `Bearer resource_metadata="${metadataUrl}"`
|
|
59
|
+
},
|
|
60
|
+
status: HTTP_UNAUTHORIZED
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// src/dispatch.ts
|
|
64
|
+
var DEFAULT_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
65
|
+
var DEFAULT_RESOURCE_MIME = "text/markdown";
|
|
66
|
+
var idOf = (message) => typeof message.id === "string" || typeof message.id === "number" ? message.id : null;
|
|
67
|
+
var negotiateProtocol = (supported, params) => {
|
|
68
|
+
const preferred = supported[0] ?? DEFAULT_PROTOCOLS[0] ?? "";
|
|
69
|
+
const requested = isRecord(params) && typeof params.protocolVersion === "string" ? params.protocolVersion : preferred;
|
|
70
|
+
return supported.includes(requested) ? requested : preferred;
|
|
71
|
+
};
|
|
72
|
+
var initialize = (config, id, params) => {
|
|
73
|
+
const supported = config.supportedProtocols ?? DEFAULT_PROTOCOLS;
|
|
74
|
+
const capabilities = {
|
|
75
|
+
tools: { listChanged: false }
|
|
76
|
+
};
|
|
77
|
+
if (config.prompts)
|
|
78
|
+
capabilities.prompts = { listChanged: false };
|
|
79
|
+
if (config.resources) {
|
|
80
|
+
capabilities.resources = { listChanged: false, subscribe: false };
|
|
81
|
+
}
|
|
82
|
+
return rpcResult(id, {
|
|
83
|
+
capabilities,
|
|
84
|
+
...config.instructions === undefined ? {} : { instructions: config.instructions },
|
|
85
|
+
protocolVersion: negotiateProtocol(supported, params),
|
|
86
|
+
serverInfo: config.serverInfo
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
var toolsList = async (config, caller, id) => {
|
|
90
|
+
const tools = await config.tools({ caller, meta: {} });
|
|
91
|
+
return rpcResult(id, {
|
|
92
|
+
tools: Object.entries(tools).map(([name, tool]) => ({
|
|
93
|
+
annotations: tool.annotations,
|
|
94
|
+
description: tool.description,
|
|
95
|
+
inputSchema: tool.inputSchema,
|
|
96
|
+
name
|
|
97
|
+
}))
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
var errorResult = (id, text) => rpcResult(id, { content: [{ text, type: "text" }], isError: true });
|
|
101
|
+
var toolsCall = async (config, caller, id, params) => {
|
|
102
|
+
if (!isRecord(params) || typeof params.name !== "string") {
|
|
103
|
+
return rpcError(id, JSONRPC_INVALID_PARAMS, "tools/call needs a name");
|
|
104
|
+
}
|
|
105
|
+
const name = params.name;
|
|
106
|
+
const args = params.arguments ?? {};
|
|
107
|
+
const meta = {};
|
|
108
|
+
if (config.beforeCall) {
|
|
109
|
+
const gate = await config.beforeCall({ args, caller, meta, name });
|
|
110
|
+
if (gate)
|
|
111
|
+
return errorResult(id, gate.block);
|
|
112
|
+
}
|
|
113
|
+
const tools = await config.tools({ caller, meta });
|
|
114
|
+
const tool = tools[name];
|
|
115
|
+
if (!tool)
|
|
116
|
+
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
117
|
+
let ok = false;
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
const text = await tool.handler(args);
|
|
121
|
+
ok = true;
|
|
122
|
+
response = rpcResult(id, {
|
|
123
|
+
content: [{ text, type: "text" }],
|
|
124
|
+
isError: false
|
|
125
|
+
});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const detail = error instanceof Error ? error.message : "unknown error";
|
|
128
|
+
response = errorResult(id, `Tool failed: ${detail}`);
|
|
129
|
+
}
|
|
130
|
+
if (config.onCall)
|
|
131
|
+
await config.onCall({ args, caller, meta, name, ok });
|
|
132
|
+
return response;
|
|
133
|
+
};
|
|
134
|
+
var promptsList = (config, id) => {
|
|
135
|
+
const definitions = config.prompts?.definitions ?? {};
|
|
136
|
+
return rpcResult(id, {
|
|
137
|
+
prompts: Object.entries(definitions).map(([name, def]) => ({
|
|
138
|
+
arguments: def.arguments ?? [],
|
|
139
|
+
description: def.description,
|
|
140
|
+
name,
|
|
141
|
+
title: def.title
|
|
142
|
+
}))
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
var promptsGet = async (config, caller, id, params) => {
|
|
146
|
+
const prompts = config.prompts;
|
|
147
|
+
if (!prompts)
|
|
148
|
+
return rpcError(id, JSONRPC_METHOD_NOT_FOUND, "No prompts");
|
|
149
|
+
if (!isRecord(params) || typeof params.name !== "string") {
|
|
150
|
+
return rpcError(id, JSONRPC_INVALID_PARAMS, "prompts/get needs a name");
|
|
151
|
+
}
|
|
152
|
+
const def = prompts.definitions[params.name];
|
|
153
|
+
if (!def) {
|
|
154
|
+
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown prompt: ${params.name}`);
|
|
155
|
+
}
|
|
156
|
+
const args = isRecord(params.arguments) ? params.arguments : {};
|
|
157
|
+
const text = await prompts.get({ args, caller, name: params.name });
|
|
158
|
+
if (text === null) {
|
|
159
|
+
return rpcError(id, JSONRPC_INTERNAL_ERROR, "Prompt failed to build");
|
|
160
|
+
}
|
|
161
|
+
return rpcResult(id, {
|
|
162
|
+
description: def.description,
|
|
163
|
+
messages: [{ content: { text, type: "text" }, role: "user" }]
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
var resourcesList = async (config, caller, id) => {
|
|
167
|
+
const resources = config.resources;
|
|
168
|
+
if (!resources)
|
|
169
|
+
return rpcResult(id, { resources: [] });
|
|
170
|
+
return rpcResult(id, { resources: await resources.list({ caller }) });
|
|
171
|
+
};
|
|
172
|
+
var resourcesRead = async (config, caller, id, params) => {
|
|
173
|
+
const resources = config.resources;
|
|
174
|
+
if (!resources)
|
|
175
|
+
return rpcError(id, JSONRPC_METHOD_NOT_FOUND, "No resources");
|
|
176
|
+
if (!isRecord(params) || typeof params.uri !== "string") {
|
|
177
|
+
return rpcError(id, JSONRPC_INVALID_PARAMS, "resources/read needs a uri");
|
|
178
|
+
}
|
|
179
|
+
const uri = params.uri;
|
|
180
|
+
const text = await resources.read({ caller, uri });
|
|
181
|
+
if (text === null) {
|
|
182
|
+
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown resource: ${uri}`);
|
|
183
|
+
}
|
|
184
|
+
return rpcResult(id, {
|
|
185
|
+
contents: [
|
|
186
|
+
{ mimeType: resources.mimeType ?? DEFAULT_RESOURCE_MIME, text, uri }
|
|
187
|
+
]
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
var dispatchMcp = async (config, caller, message) => {
|
|
191
|
+
if (!isRecord(message) || message.jsonrpc !== "2.0") {
|
|
192
|
+
return rpcError(null, JSONRPC_INVALID_REQUEST, "Not a JSON-RPC 2.0 message");
|
|
193
|
+
}
|
|
194
|
+
if (!("id" in message))
|
|
195
|
+
return notificationAck();
|
|
196
|
+
const id = idOf(message);
|
|
197
|
+
const method = typeof message.method === "string" ? message.method : "";
|
|
198
|
+
const { params } = message;
|
|
199
|
+
if (method === "initialize")
|
|
200
|
+
return initialize(config, id, params);
|
|
201
|
+
if (method === "ping")
|
|
202
|
+
return rpcResult(id, {});
|
|
203
|
+
if (method === "tools/list")
|
|
204
|
+
return toolsList(config, caller, id);
|
|
205
|
+
if (method === "tools/call")
|
|
206
|
+
return toolsCall(config, caller, id, params);
|
|
207
|
+
if (method === "prompts/list")
|
|
208
|
+
return promptsList(config, id);
|
|
209
|
+
if (method === "prompts/get")
|
|
210
|
+
return promptsGet(config, caller, id, params);
|
|
211
|
+
if (method === "resources/list")
|
|
212
|
+
return resourcesList(config, caller, id);
|
|
213
|
+
if (method === "resources/read") {
|
|
214
|
+
return resourcesRead(config, caller, id, params);
|
|
215
|
+
}
|
|
216
|
+
return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
217
|
+
};
|
|
218
|
+
// src/metadata.ts
|
|
219
|
+
var protectedResourceMetadata = (input) => ({
|
|
220
|
+
authorization_servers: [input.issuer],
|
|
221
|
+
resource: input.resource,
|
|
222
|
+
scopes_supported: input.scopes ?? []
|
|
223
|
+
});
|
|
224
|
+
var metadataPathFor = (path) => `/.well-known/oauth-protected-resource${path}`;
|
|
225
|
+
// src/server.ts
|
|
226
|
+
import { Elysia } from "elysia";
|
|
227
|
+
var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
|
|
228
|
+
var mcpServer = (config) => {
|
|
229
|
+
const metadataPath = metadataPathFor(config.path);
|
|
230
|
+
const metadataUrl = `${config.issuer}${metadataPath}`;
|
|
231
|
+
const metadata = () => protectedResourceMetadata({
|
|
232
|
+
issuer: config.issuer,
|
|
233
|
+
resource: `${config.issuer}${config.path}`,
|
|
234
|
+
scopes: config.scopesSupported
|
|
235
|
+
});
|
|
236
|
+
const base = new Elysia().get(metadataPath, metadata).get(config.path, () => new Response(null, { status: HTTP_METHOD_NOT_ALLOWED })).post(config.path, async ({ body, request }) => {
|
|
237
|
+
const auth = await config.authorize(request);
|
|
238
|
+
if (!auth.ok)
|
|
239
|
+
return unauthorized(metadataUrl, auth.reason);
|
|
240
|
+
const message = body;
|
|
241
|
+
if (message === undefined || message === null) {
|
|
242
|
+
return rpcError(null, JSONRPC_PARSE_ERROR, "Invalid JSON");
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(message)) {
|
|
245
|
+
return rpcError(null, JSONRPC_INVALID_REQUEST, "Batching is not supported");
|
|
246
|
+
}
|
|
247
|
+
return dispatchMcp(config, auth.caller, message).catch(() => rpcError(null, JSONRPC_INVALID_REQUEST, "Internal error"));
|
|
248
|
+
});
|
|
249
|
+
const app = config.serveRootMetadata ? base.get(ROOT_METADATA_PATH, metadata) : base;
|
|
250
|
+
return app;
|
|
251
|
+
};
|
|
252
|
+
export {
|
|
253
|
+
verifyBearer,
|
|
254
|
+
protectedResourceMetadata,
|
|
255
|
+
metadataPathFor,
|
|
256
|
+
mcpServer,
|
|
257
|
+
dispatchMcp
|
|
258
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The decoded JWT, as returned by your `verify`. Only `payload` is read. */
|
|
2
|
+
export type VerifiedJwt = {
|
|
3
|
+
header?: unknown;
|
|
4
|
+
payload: unknown;
|
|
5
|
+
};
|
|
6
|
+
export type BearerVerifier = (token: string) => Promise<VerifiedJwt | undefined> | VerifiedJwt | undefined;
|
|
7
|
+
export type VerifyBearerConfig = {
|
|
8
|
+
issuer: string;
|
|
9
|
+
request: Request;
|
|
10
|
+
/** Verify the JWT signature and decode it (e.g. `@absolutejs/auth`'s
|
|
11
|
+
* `verifyJwt`). Return undefined on an invalid signature. */
|
|
12
|
+
verify: BearerVerifier;
|
|
13
|
+
requiredScope?: string;
|
|
14
|
+
};
|
|
15
|
+
export type BearerResult = {
|
|
16
|
+
error: string;
|
|
17
|
+
} | {
|
|
18
|
+
payload: Record<string, unknown>;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
subject: string;
|
|
21
|
+
};
|
|
22
|
+
/** Verify the `Authorization: Bearer` token: signature (via your `verify`),
|
|
23
|
+
* `token_use: access`, issuer, expiry, the required scope, and a subject.
|
|
24
|
+
* Returns the decoded payload + parsed scopes + subject, or a reason string.
|
|
25
|
+
* The reason is safe to surface in the 401 (never why the signature failed). */
|
|
26
|
+
export declare const verifyBearer: (config: VerifyBearerConfig) => Promise<BearerResult>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServerConfig } from "./types";
|
|
2
|
+
/** Route one decoded JSON-RPC message to its handler. Notifications (no `id`)
|
|
3
|
+
* get a bare 202. Unknown methods get JSON-RPC method-not-found. */
|
|
4
|
+
export declare const dispatchMcp: <Caller>(config: McpServerConfig<Caller>, caller: Caller, message: unknown) => Promise<Response>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@absolutejs/mcp` — serve a remote Model Context Protocol endpoint
|
|
3
|
+
* (streamable HTTP, stateless) from a tool/prompt/resource registry.
|
|
4
|
+
*
|
|
5
|
+
* Define the endpoint once with {@link mcpServer} and mount it on your Elysia
|
|
6
|
+
* app. You supply WHICH tools to expose and HOW to authorize a request into a
|
|
7
|
+
* caller ({@link verifyBearer} does the standard OAuth bearer checks against
|
|
8
|
+
* any authorization server); the package owns the JSON-RPC protocol, protocol
|
|
9
|
+
* negotiation, RFC 9728 discovery metadata, and the 401 challenge. Per-call
|
|
10
|
+
* guards (`beforeCall` to gate on credits/rate limits, `onCall` to audit) and a
|
|
11
|
+
* per-call `meta` scratchpad are hooks — the package ships no opinions about
|
|
12
|
+
* billing, storage, or who is allowed in.
|
|
13
|
+
*
|
|
14
|
+
* The tool shape is structurally compatible with `@absolutejs/ai`'s
|
|
15
|
+
* `AIToolMap`, so an AI tool registry serves over MCP without conversion — but
|
|
16
|
+
* nothing here depends on a model: any typed tool registry works.
|
|
17
|
+
*/
|
|
18
|
+
export { verifyBearer, type BearerResult, type BearerVerifier, type VerifiedJwt, type VerifyBearerConfig, } from "./auth";
|
|
19
|
+
export { dispatchMcp } from "./dispatch";
|
|
20
|
+
export { metadataPathFor, protectedResourceMetadata, type ProtectedResourceMetadata, } from "./metadata";
|
|
21
|
+
export { mcpServer } from "./server";
|
|
22
|
+
export type { McpAuthResult, McpCallGate, McpCallMeta, McpPromptArgument, McpPromptDefinition, McpPrompts, McpResource, McpResources, McpServerConfig, McpServerInfo, McpTool, McpToolAnnotations, McpToolContext, McpToolRegistry, } from "./types";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const JSONRPC_PARSE_ERROR = -32700;
|
|
2
|
+
export declare const JSONRPC_INVALID_REQUEST = -32600;
|
|
3
|
+
export declare const JSONRPC_METHOD_NOT_FOUND = -32601;
|
|
4
|
+
export declare const JSONRPC_INVALID_PARAMS = -32602;
|
|
5
|
+
export declare const JSONRPC_INTERNAL_ERROR = -32603;
|
|
6
|
+
export declare const HTTP_ACCEPTED = 202;
|
|
7
|
+
export declare const HTTP_UNAUTHORIZED = 401;
|
|
8
|
+
export declare const HTTP_METHOD_NOT_ALLOWED = 405;
|
|
9
|
+
export type JsonRpcId = string | number | null;
|
|
10
|
+
export declare const rpcResult: (id: JsonRpcId, result: unknown) => Response;
|
|
11
|
+
export declare const rpcError: (id: JsonRpcId, code: number, message: string) => Response;
|
|
12
|
+
export declare const notificationAck: () => Response;
|
|
13
|
+
/** 401 with the RFC 9728 `WWW-Authenticate` challenge pointing at the
|
|
14
|
+
* protected-resource metadata, so the client can discover the auth server. */
|
|
15
|
+
export declare const unauthorized: (metadataUrl: string, detail: string) => Response;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ProtectedResourceMetadata = {
|
|
2
|
+
authorization_servers: string[];
|
|
3
|
+
resource: string;
|
|
4
|
+
scopes_supported: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare const protectedResourceMetadata: (input: {
|
|
7
|
+
issuer: string;
|
|
8
|
+
resource: string;
|
|
9
|
+
scopes?: string[];
|
|
10
|
+
}) => ProtectedResourceMetadata;
|
|
11
|
+
/** The metadata document URL for an endpoint path, per RFC 9728 §3: the path is
|
|
12
|
+
* inserted after the well-known segment. `/mcp` → `/.well-known/oauth-protected-resource/mcp`. */
|
|
13
|
+
export declare const metadataPathFor: (path: string) => string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
import type { McpServerConfig } from "./types";
|
|
3
|
+
/** Build the MCP endpoint as an Elysia plugin. Mount it with `.use(...)`.
|
|
4
|
+
*
|
|
5
|
+
* Serves:
|
|
6
|
+
* - `POST <path>` — the JSON-RPC endpoint (stateless streamable HTTP)
|
|
7
|
+
* - `GET <path>` — 405 (no server-initiated stream to subscribe to)
|
|
8
|
+
* - `GET /.well-known/oauth-protected-resource<path>` — RFC 9728 metadata
|
|
9
|
+
* - `GET /.well-known/oauth-protected-resource` — the same, if
|
|
10
|
+
* `serveRootMetadata` is set (only one endpoint per app should). */
|
|
11
|
+
export declare const mcpServer: <Caller>(config: McpServerConfig<Caller>) => Elysia;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** MCP behaviour hints, passed straight through to the client on `tools/list`.
|
|
2
|
+
* Structurally identical to `@absolutejs/ai`'s `AIToolAnnotations`, so a tool
|
|
3
|
+
* map from that package satisfies this without conversion. All optional. */
|
|
4
|
+
export type McpToolAnnotations = {
|
|
5
|
+
destructiveHint?: boolean;
|
|
6
|
+
idempotentHint?: boolean;
|
|
7
|
+
openWorldHint?: boolean;
|
|
8
|
+
readOnlyHint?: boolean;
|
|
9
|
+
title?: string;
|
|
10
|
+
};
|
|
11
|
+
/** One callable tool. `inputSchema` is a JSON Schema object; `handler` receives
|
|
12
|
+
* the client's `arguments` and returns text (the tool's `content`). */
|
|
13
|
+
export type McpTool = {
|
|
14
|
+
annotations?: McpToolAnnotations;
|
|
15
|
+
description: string;
|
|
16
|
+
handler: (args: unknown) => Promise<string> | string;
|
|
17
|
+
inputSchema: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
export type McpToolRegistry = Record<string, McpTool>;
|
|
20
|
+
/** A resource the client can list and read (`resources/list` / `resources/read`). */
|
|
21
|
+
export type McpResource = {
|
|
22
|
+
description?: string;
|
|
23
|
+
mimeType?: string;
|
|
24
|
+
name: string;
|
|
25
|
+
uri: string;
|
|
26
|
+
};
|
|
27
|
+
export type McpPromptArgument = {
|
|
28
|
+
description: string;
|
|
29
|
+
name: string;
|
|
30
|
+
required?: boolean;
|
|
31
|
+
};
|
|
32
|
+
export type McpPromptDefinition = {
|
|
33
|
+
arguments?: McpPromptArgument[];
|
|
34
|
+
description: string;
|
|
35
|
+
title: string;
|
|
36
|
+
};
|
|
37
|
+
/** Per-call scratchpad shared between `tools`, `beforeCall`, and `onCall` for
|
|
38
|
+
* one `tools/call` request. A tool handler can write to it (e.g. record which
|
|
39
|
+
* entity it touched) and `onCall` can read it back for the audit row. */
|
|
40
|
+
export type McpCallMeta = Record<string, unknown>;
|
|
41
|
+
/** What `authorize` returns: the resolved caller, or a reason for the 401. */
|
|
42
|
+
export type McpAuthResult<Caller> = {
|
|
43
|
+
caller: Caller;
|
|
44
|
+
ok: true;
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
reason: string;
|
|
48
|
+
};
|
|
49
|
+
/** Returned by `beforeCall` to refuse a tool call without running it — the
|
|
50
|
+
* message becomes an `isError` tool result (a paused/rate-limited notice the
|
|
51
|
+
* model can relay), not a transport error. */
|
|
52
|
+
export type McpCallGate = {
|
|
53
|
+
block: string;
|
|
54
|
+
};
|
|
55
|
+
export type McpToolContext<Caller> = {
|
|
56
|
+
caller: Caller;
|
|
57
|
+
meta: McpCallMeta;
|
|
58
|
+
};
|
|
59
|
+
export type McpPrompts<Caller> = {
|
|
60
|
+
definitions: Record<string, McpPromptDefinition>;
|
|
61
|
+
get: (ctx: {
|
|
62
|
+
args: Record<string, unknown>;
|
|
63
|
+
caller: Caller;
|
|
64
|
+
name: string;
|
|
65
|
+
}) => Promise<string | null> | string | null;
|
|
66
|
+
};
|
|
67
|
+
export type McpResources<Caller> = {
|
|
68
|
+
/** Defaults to "text/markdown". */
|
|
69
|
+
mimeType?: string;
|
|
70
|
+
list: (ctx: {
|
|
71
|
+
caller: Caller;
|
|
72
|
+
}) => Promise<McpResource[]> | McpResource[];
|
|
73
|
+
read: (ctx: {
|
|
74
|
+
caller: Caller;
|
|
75
|
+
uri: string;
|
|
76
|
+
}) => Promise<string | null> | string | null;
|
|
77
|
+
};
|
|
78
|
+
export type McpServerInfo = {
|
|
79
|
+
name: string;
|
|
80
|
+
title?: string;
|
|
81
|
+
version: string;
|
|
82
|
+
};
|
|
83
|
+
export type McpServerConfig<Caller> = {
|
|
84
|
+
/** Resolve the request into a caller, or a reason for the 401. The package
|
|
85
|
+
* emits the 401 + RFC 9728 `WWW-Authenticate` challenge; you decide who is
|
|
86
|
+
* allowed in. See {@link verifyBearer} for the standard token checks. */
|
|
87
|
+
authorize: (request: Request) => Promise<McpAuthResult<Caller>>;
|
|
88
|
+
/** Refuse a single `tools/call` before it runs (credits exhausted, rate
|
|
89
|
+
* limited). Return `{ block }` to short-circuit; return nothing to proceed. */
|
|
90
|
+
beforeCall?: (ctx: {
|
|
91
|
+
args: unknown;
|
|
92
|
+
caller: Caller;
|
|
93
|
+
meta: McpCallMeta;
|
|
94
|
+
name: string;
|
|
95
|
+
}) => Promise<McpCallGate | void> | McpCallGate | void;
|
|
96
|
+
instructions?: string;
|
|
97
|
+
/** The token issuer — used for discovery metadata and the challenge URL. */
|
|
98
|
+
issuer: string;
|
|
99
|
+
/** Fired after every `tools/call` for auditing. `meta` carries anything the
|
|
100
|
+
* tool handler wrote during the call. */
|
|
101
|
+
onCall?: (record: {
|
|
102
|
+
args: unknown;
|
|
103
|
+
caller: Caller;
|
|
104
|
+
meta: McpCallMeta;
|
|
105
|
+
name: string;
|
|
106
|
+
ok: boolean;
|
|
107
|
+
}) => Promise<void> | void;
|
|
108
|
+
/** The endpoint path, e.g. "/mcp" or "/mcp/admin". */
|
|
109
|
+
path: string;
|
|
110
|
+
prompts?: McpPrompts<Caller>;
|
|
111
|
+
resources?: McpResources<Caller>;
|
|
112
|
+
/** Advertised in the protected-resource metadata. */
|
|
113
|
+
scopesSupported?: string[];
|
|
114
|
+
/** Also serve the un-suffixed `/.well-known/oauth-protected-resource` alias
|
|
115
|
+
* (some clients probe the root). Only one endpoint per app may set this. */
|
|
116
|
+
serveRootMetadata?: boolean;
|
|
117
|
+
serverInfo: McpServerInfo;
|
|
118
|
+
/** Protocol versions this endpoint accepts; the first is the preferred one.
|
|
119
|
+
* Defaults to the versions this package knows. */
|
|
120
|
+
supportedProtocols?: string[];
|
|
121
|
+
/** Build the tool registry for this caller. Called once per request. */
|
|
122
|
+
tools: (ctx: McpToolContext<Caller>) => McpToolRegistry | Promise<McpToolRegistry>;
|
|
123
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Alex Kahn",
|
|
3
|
+
"description": "Serve a remote Model Context Protocol endpoint (streamable HTTP, stateless) from a tool/prompt/resource registry — OAuth bearer auth, RFC 9728 discovery, and per-call guards are yours to wire, the JSON-RPC protocol is done correctly by construction.",
|
|
4
|
+
"devDependencies": {
|
|
5
|
+
"@types/bun": "latest",
|
|
6
|
+
"elysia": "^1.1.0",
|
|
7
|
+
"prettier": "3.5.3",
|
|
8
|
+
"typescript": "5.8.3"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"elysia": ">=1.1.0"
|
|
12
|
+
},
|
|
13
|
+
"license": "BUSL-1.1",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"name": "@absolutejs/mcp",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/absolutejs/mcp.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/absolutejs/mcp",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/absolutejs/mcp/issues"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "rm -rf dist && bun build src/index.ts --outdir dist --target=bun --external elysia && tsc --emitDeclarationOnly --project tsconfig.json",
|
|
34
|
+
"format": "prettier --write \"./**/*.{ts,json,md}\"",
|
|
35
|
+
"release": "bun run format && bun run test && bun run build && bun publish",
|
|
36
|
+
"test": "bun test",
|
|
37
|
+
"typecheck": "tsc --noEmit --project tsconfig.json"
|
|
38
|
+
},
|
|
39
|
+
"types": "./dist/src/index.d.ts",
|
|
40
|
+
"version": "0.0.1"
|
|
41
|
+
}
|