-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathindex.js
More file actions
212 lines (186 loc) · 5.97 KB
/
Copy pathindex.js
File metadata and controls
212 lines (186 loc) · 5.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
/**
* MCP (Model Context Protocol) plugin.
*
* Usage:
* createServer({ mcp: true })
*
* Endpoint:
* POST /mcp (JSON-RPC 2.0, MCP Streamable HTTP transport)
*
* Auth:
* Reuses JSS's existing auth chain — Bearer / DPoP / NIP-98 — so
* the same WAC rules that gate /public, /private, etc. also gate
* tool calls. Anonymous requests get the same WAC treatment as
* any other anonymous request.
*
* Spec: https://spec.modelcontextprotocol.io/specification/2025-03-26/
*/
import {
PROTOCOL_VERSION,
SERVER_INFO,
RPC_ERRORS,
rpcResult,
rpcError
} from './protocol.js';
import { listToolsForRpc, callTool, TOOLS } from './tools.js';
import { getWebIdFromRequestAsync } from '../auth/token.js';
const ALLOWED_METHODS = new Set([
'initialize',
'initialized',
'notifications/initialized',
'tools/list',
'tools/call',
'ping'
]);
function originOf(request) {
const host = request.headers.host || request.hostname;
const proto = request.protocol || 'http';
return `${proto}://${host}`;
}
async function dispatch(msg, ctx) {
const { id, method, params } = msg;
if (!ALLOWED_METHODS.has(method)) {
return rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, `unknown method: ${method}`);
}
if (method === 'ping') {
return rpcResult(id, {});
}
if (method === 'initialize') {
return rpcResult(id, {
protocolVersion: PROTOCOL_VERSION,
serverInfo: SERVER_INFO,
capabilities: {
tools: { listChanged: false }
}
});
}
if (method === 'initialized' || method === 'notifications/initialized') {
// Notifications carry no id; nothing to return
return null;
}
if (method === 'tools/list') {
return rpcResult(id, { tools: listToolsForRpc() });
}
if (method === 'tools/call') {
const toolName = params?.name;
const toolArgs = params?.arguments || {};
if (!toolName) {
return rpcError(id, RPC_ERRORS.INVALID_PARAMS, 'tool name required');
}
const result = await callTool(toolName, toolArgs, ctx);
return rpcResult(id, result);
}
return rpcError(id, RPC_ERRORS.METHOD_NOT_FOUND, `unhandled method: ${method}`);
}
function isStreamingToolCall(body) {
return body
&& body.method === 'tools/call'
&& body.params?.name
&& TOOLS[body.params.name]
// Sniff: invoke the handler synchronously so we can detect the
// `{ stream: true, init, run }` shape. Streaming tools must be
// pure-synchronous in their shape-decision (no awaits before
// returning the stream descriptor).
&& (() => {
try {
// We can't safely call the handler without a ctx, so we just
// rely on the tool name being in our streaming-tools set.
return STREAMING_TOOLS.has(body.params.name);
} catch { return false; }
})();
}
const STREAMING_TOOLS = new Set(['subscribe']);
async function handleStreamingTool(request, reply, body, ctx) {
const tool = TOOLS[body.params.name];
let descriptor;
try {
descriptor = tool.handler(body.params.arguments || {}, ctx);
} catch (e) {
reply.code(500);
reply.header('Content-Type', 'application/json');
return rpcError(body.id, RPC_ERRORS.INTERNAL_ERROR, e.message);
}
if (!descriptor || descriptor.stream !== true) {
// Tool decided not to stream after all — emit single-shot response
reply.header('Content-Type', 'application/json');
return rpcResult(body.id, descriptor);
}
// Switch to SSE
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
const controller = new AbortController();
request.raw.on('close', () => controller.abort());
const sendEvent = (payload) => {
if (controller.signal.aborted) return;
const note = {
jsonrpc: '2.0',
method: 'notifications/tool_event',
params: { tool: body.params.name, event: payload }
};
reply.raw.write(`event: notification\ndata: ${JSON.stringify(note)}\n\n`);
};
try {
const initial = await descriptor.init();
if (initial) sendEvent(initial);
await descriptor.run(sendEvent, controller.signal);
} catch (e) {
sendEvent({ type: 'error', message: e.message });
} finally {
if (!controller.signal.aborted) reply.raw.end();
}
return reply;
}
/**
* Register the MCP plugin with Fastify.
*/
export async function mcpPlugin(fastify, _options) {
fastify.post('/mcp', async (request, reply) => {
const body = request.body;
if (!body || typeof body !== 'object') {
reply.code(400);
return rpcError(null, RPC_ERRORS.INVALID_REQUEST, 'expected JSON-RPC body');
}
// Identity for tool calls — pulled from the inbound auth on /mcp itself.
// null webId means "anonymous"; WAC will treat it accordingly.
const { webId } = await getWebIdFromRequestAsync(request).catch(() => ({ webId: null }));
// Federation depth (used by call_remote_pod to enforce the cap)
const depthHdr = request.headers['mcp-federation-depth'];
const federationDepth = depthHdr ? parseInt(depthHdr, 10) || 0 : 0;
const ctx = {
webId: webId || null,
origin: originOf(request),
federationDepth
};
// Streaming tool? Hand off to SSE handler.
if (!Array.isArray(body) && isStreamingToolCall(body)) {
return handleStreamingTool(request, reply, body, ctx);
}
// Batch support (array of requests)
if (Array.isArray(body)) {
const out = [];
for (const msg of body) {
const r = await dispatch(msg, ctx);
if (r) out.push(r);
}
reply.header('Content-Type', 'application/json');
return out;
}
const result = await dispatch(body, ctx);
if (result === null) {
// Notification (no response body)
reply.code(204);
return null;
}
reply.header('Content-Type', 'application/json');
return result;
});
fastify.options('/mcp', async (_request, reply) => {
reply.header('Allow', 'POST, OPTIONS');
reply.code(204);
return null;
});
}