@crossgen-ai/praxis-connectors 0.1.2

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/lib/oauth.js ADDED
@@ -0,0 +1,139 @@
1
+ // The store-backed OAuthClientProvider: PKCE verifier, client
2
+ // registration, and tokens all live in the box's auth/<id>.json (0600) and
3
+ // never leave it. The human leg is split exactly the way the MCP SDK
4
+ // sanctions — redirectToAuthorization() captures the URL for whichever face
5
+ // (app, CLI) can show it to a human; the code comes back through
6
+ // engine.authFinish(), however it traveled (serve callback or paste-back).
7
+ 'use strict';
8
+
9
+ const crypto = require('node:crypto');
10
+
11
+ class StoreOAuthProvider {
12
+ /**
13
+ * @param {import('./store').ConnectorsStore} store
14
+ * @param {string} id connector id
15
+ * @param {object} [opts]
16
+ * @param {string} [opts.redirectUrl] where the AS should send the code —
17
+ * serve's callback route; falls back to the one recorded when the
18
+ * flow started (refresh flows never use it).
19
+ */
20
+ constructor(store, id, { redirectUrl } = {}) {
21
+ this.store = store;
22
+ this.id = id;
23
+ // Fall back to the flow-in-progress redirect, then the one the last
24
+ // completed sign-in used — the SDK's refresh branch only runs for
25
+ // providers that look interactive (redirectUrl present).
26
+ const persisted = this.store.readAuth(id);
27
+ this._redirectUrl = redirectUrl
28
+ || persisted.pendingAuth?.redirectUrl
29
+ || persisted.redirectUrl;
30
+ this.capturedAuthorizationUrl = null;
31
+ }
32
+
33
+ get redirectUrl() {
34
+ return this._redirectUrl;
35
+ }
36
+
37
+ get clientMetadata() {
38
+ return {
39
+ client_name: 'Praxis Connectors',
40
+ ...(this._redirectUrl ? { redirect_uris: [String(this._redirectUrl)] } : {}),
41
+ grant_types: ['authorization_code', 'refresh_token'],
42
+ response_types: ['code'],
43
+ token_endpoint_auth_method: 'none',
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The OAuth state parameter doubles as the callback's router: serve's
49
+ * single callback route finds the connector by matching it (engine
50
+ * findPendingByState). Prefix is cosmetic; the entropy is the guard.
51
+ */
52
+ state() {
53
+ const auth = this.store.readAuth(this.id);
54
+ if (auth.pendingAuth?.state) return auth.pendingAuth.state;
55
+ const state = `${this.id}-${crypto.randomBytes(16).toString('hex')}`;
56
+ this.store.patchAuth(this.id, {
57
+ pendingAuth: { ...(auth.pendingAuth || {}), state, redirectUrl: this._redirectUrl },
58
+ });
59
+ return state;
60
+ }
61
+
62
+ clientInformation() {
63
+ return this.store.readAuth(this.id).clientInfo || undefined;
64
+ }
65
+
66
+ saveClientInformation(clientInformation) {
67
+ this.store.patchAuth(this.id, { clientInfo: clientInformation });
68
+ }
69
+
70
+ tokens() {
71
+ return this.store.readAuth(this.id).tokens || undefined;
72
+ }
73
+
74
+ saveTokens(tokens) {
75
+ // tokensSavedAt rides beside (not inside) the SDK's shape — the refresh
76
+ // engine computes expiry as savedAt + expires_in. The redirect URL
77
+ // outlives the flow so later refreshes count as interactive.
78
+ this.store.patchAuth(this.id, {
79
+ tokens,
80
+ tokensSavedAt: new Date().toISOString(),
81
+ pendingAuth: undefined,
82
+ ...(this._redirectUrl ? { redirectUrl: String(this._redirectUrl) } : {}),
83
+ });
84
+ }
85
+
86
+ redirectToAuthorization(authorizationUrl) {
87
+ // No browser on the box: capture the URL for the relay.
88
+ this.capturedAuthorizationUrl = String(authorizationUrl);
89
+ const auth = this.store.readAuth(this.id);
90
+ this.store.patchAuth(this.id, {
91
+ pendingAuth: {
92
+ ...(auth.pendingAuth || {}),
93
+ url: this.capturedAuthorizationUrl,
94
+ redirectUrl: this._redirectUrl,
95
+ startedAt: new Date().toISOString(),
96
+ },
97
+ });
98
+ }
99
+
100
+ saveCodeVerifier(codeVerifier) {
101
+ this.store.patchAuth(this.id, { codeVerifier });
102
+ }
103
+
104
+ /**
105
+ * SEP-2352: discovery state persists alongside the verifier so the
106
+ * callback leg can prove the code came from the same authorization
107
+ * server the flow started with (mix-up defense).
108
+ */
109
+ discoveryState() {
110
+ return this.store.readAuth(this.id).discoveryState || undefined;
111
+ }
112
+
113
+ saveDiscoveryState(state) {
114
+ this.store.patchAuth(this.id, { discoveryState: state });
115
+ }
116
+
117
+ codeVerifier() {
118
+ const v = this.store.readAuth(this.id).codeVerifier;
119
+ if (!v) throw new Error(`no sign-in in progress for connector ${this.id}`);
120
+ return v;
121
+ }
122
+
123
+ invalidateCredentials(scope) {
124
+ if (scope === 'all') {
125
+ this.store.patchAuth(this.id, {
126
+ tokens: undefined, tokensSavedAt: undefined,
127
+ clientInfo: undefined, codeVerifier: undefined,
128
+ });
129
+ } else if (scope === 'client') {
130
+ this.store.patchAuth(this.id, { clientInfo: undefined });
131
+ } else if (scope === 'tokens') {
132
+ this.store.patchAuth(this.id, { tokens: undefined, tokensSavedAt: undefined });
133
+ } else if (scope === 'verifier') {
134
+ this.store.patchAuth(this.id, { codeVerifier: undefined });
135
+ }
136
+ }
137
+ }
138
+
139
+ module.exports = { StoreOAuthProvider };
package/lib/store.js ADDED
@@ -0,0 +1,193 @@
1
+ // The box-local connector store: one directory per box is the whole sharing
2
+ // contract. Whoever writes, everyone sees — every operation re-reads from
3
+ // disk, so a long-lived host process, an interactive harness session, and a
4
+ // scheduled run never hold stale copies.
5
+ //
6
+ // $PRAXIS_CONNECTORS_HOME (default ~/.praxis-connectors)/
7
+ // connectors.json the registry (no secrets, ever)
8
+ // auth/<id>.json tokens + OAuth client state, 0600
9
+ // cache/<id>.json cached tool lists (deferred loading)
10
+ 'use strict';
11
+
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+
16
+ // Lowercase/underscore only: the harness's tools.json surface schema pins
17
+ // tool names to ^[a-z][a-z0-9_]*$, and connector ids become tool-name
18
+ // segments (mcp__<id>__<tool>).
19
+ const ID_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
20
+
21
+ const TRANSPORTS = ['stdio', 'http'];
22
+
23
+ function resolveHome(explicit) {
24
+ return explicit
25
+ || process.env.PRAXIS_CONNECTORS_HOME
26
+ || path.join(os.homedir(), '.praxis-connectors');
27
+ }
28
+
29
+ function validateId(id) {
30
+ if (!ID_PATTERN.test(String(id || ''))) {
31
+ throw new Error(
32
+ `connector id must be lowercase letters, digits, or underscores (got ${JSON.stringify(id)})`,
33
+ );
34
+ }
35
+ return String(id);
36
+ }
37
+
38
+ class ConnectorsStore {
39
+ constructor(home) {
40
+ this.home = resolveHome(home);
41
+ this.registryFile = path.join(this.home, 'connectors.json');
42
+ this.authDir = path.join(this.home, 'auth');
43
+ this.cacheDir = path.join(this.home, 'cache');
44
+ }
45
+
46
+ _ensureDirs() {
47
+ fs.mkdirSync(this.home, { recursive: true });
48
+ fs.mkdirSync(this.authDir, { recursive: true, mode: 0o700 });
49
+ fs.mkdirSync(this.cacheDir, { recursive: true });
50
+ }
51
+
52
+ _readJson(file, fallback) {
53
+ try {
54
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ } catch {
56
+ return fallback;
57
+ }
58
+ }
59
+
60
+ _writeJson(file, data, { mode } = {}) {
61
+ this._ensureDirs();
62
+ const tmp = `${file}.tmp`;
63
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 1), mode ? { mode } : {});
64
+ fs.renameSync(tmp, file);
65
+ }
66
+
67
+ /* ---- registry ---- */
68
+
69
+ readRegistry() {
70
+ const reg = this._readJson(this.registryFile, { version: 1, connectors: [] });
71
+ if (!Array.isArray(reg.connectors)) reg.connectors = [];
72
+ return reg;
73
+ }
74
+
75
+ list() {
76
+ return this.readRegistry().connectors;
77
+ }
78
+
79
+ get(id) {
80
+ return this.list().find((c) => c.id === id) || null;
81
+ }
82
+
83
+ /**
84
+ * Add or replace a registry entry. `entry`: { id, name, transport,
85
+ * command?, args?, url?, catalog?, addedBy? }. Secrets never enter the
86
+ * registry — tokens live in auth/, and command/url are configuration.
87
+ */
88
+ upsert(entry) {
89
+ const id = validateId(entry.id);
90
+ if (!TRANSPORTS.includes(entry.transport)) {
91
+ throw new Error(`transport must be one of ${TRANSPORTS.join(', ')}`);
92
+ }
93
+ if (entry.transport === 'stdio' && !entry.command) {
94
+ throw new Error('a stdio connector needs a command');
95
+ }
96
+ if (entry.transport === 'http') {
97
+ let url;
98
+ try {
99
+ url = new URL(String(entry.url || ''));
100
+ } catch {
101
+ throw new Error('an http connector needs a valid URL');
102
+ }
103
+ if (!/^https?:$/.test(url.protocol)) {
104
+ throw new Error('an http connector URL must be http:// or https://');
105
+ }
106
+ }
107
+ const clean = {
108
+ id,
109
+ name: String(entry.name || id),
110
+ transport: entry.transport,
111
+ ...(entry.transport === 'stdio'
112
+ ? { command: String(entry.command), args: (entry.args || []).map(String) }
113
+ : { url: String(entry.url) }),
114
+ ...(entry.catalog ? { catalog: String(entry.catalog) } : {}),
115
+ addedBy: String(entry.addedBy || 'unknown'),
116
+ addedAt: entry.addedAt || new Date().toISOString(),
117
+ };
118
+ const reg = this.readRegistry();
119
+ const idx = reg.connectors.findIndex((c) => c.id === id);
120
+ if (idx === -1) reg.connectors.push(clean);
121
+ else reg.connectors[idx] = { ...reg.connectors[idx], ...clean };
122
+ this._writeJson(this.registryFile, reg);
123
+ return clean;
124
+ }
125
+
126
+ remove(id) {
127
+ const reg = this.readRegistry();
128
+ const idx = reg.connectors.findIndex((c) => c.id === id);
129
+ if (idx === -1) return false;
130
+ reg.connectors.splice(idx, 1);
131
+ this._writeJson(this.registryFile, reg);
132
+ for (const file of [this._authFile(id), this._cacheFile(id)]) {
133
+ try { fs.rmSync(file); } catch { /* already gone */ }
134
+ }
135
+ return true;
136
+ }
137
+
138
+ /* ---- auth (0600: tokens are born, live, and die here) ---- */
139
+
140
+ _authFile(id) {
141
+ return path.join(this.authDir, `${validateId(id)}.json`);
142
+ }
143
+
144
+ readAuth(id) {
145
+ return this._readJson(this._authFile(id), {});
146
+ }
147
+
148
+ writeAuth(id, data) {
149
+ this._writeJson(this._authFile(id), data, { mode: 0o600 });
150
+ // rename preserves the tmp file's mode; belt-and-braces on reused paths.
151
+ try { fs.chmodSync(this._authFile(id), 0o600); } catch { /* best effort */ }
152
+ }
153
+
154
+ patchAuth(id, patch) {
155
+ const next = { ...this.readAuth(id), ...patch };
156
+ for (const [k, v] of Object.entries(patch)) {
157
+ if (v === undefined) delete next[k];
158
+ }
159
+ this.writeAuth(id, next);
160
+ return next;
161
+ }
162
+
163
+ /* ---- tool cache (roster-style: listing never blocks a spawn) ---- */
164
+
165
+ _cacheFile(id) {
166
+ return path.join(this.cacheDir, `${validateId(id)}.json`);
167
+ }
168
+
169
+ readCache(id) {
170
+ return this._readJson(this._cacheFile(id), null);
171
+ }
172
+
173
+ writeCache(id, { tools, status }) {
174
+ this._writeJson(this._cacheFile(id), {
175
+ tools: tools || [],
176
+ status: status || 'connected',
177
+ listedAt: new Date().toISOString(),
178
+ });
179
+ }
180
+
181
+ /** Status bookkeeping rides the cache file so list() stays disk-only. */
182
+ writeStatus(id, status, detail) {
183
+ const prev = this.readCache(id) || { tools: [] };
184
+ this._writeJson(this._cacheFile(id), {
185
+ ...prev,
186
+ status,
187
+ ...(detail ? { statusDetail: String(detail) } : { statusDetail: undefined }),
188
+ statusAt: new Date().toISOString(),
189
+ });
190
+ }
191
+ }
192
+
193
+ module.exports = { ConnectorsStore, resolveHome, validateId, ID_PATTERN };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@crossgen-ai/praxis-connectors",
3
+ "version": "0.1.2",
4
+ "description": "An MCP connector engine for the Praxis platform.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./extension": "./extension/index.js",
11
+ "./test-helpers": "./test/helpers/index.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "pi": {
15
+ "extensions": ["./extension/index.js"]
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "extension",
20
+ "test/helpers",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "keywords": [
30
+ "praxis",
31
+ "mcp",
32
+ "connectors",
33
+ "pi-package"
34
+ ],
35
+ "dependencies": {
36
+ "@modelcontextprotocol/client": "^2.0.0"
37
+ }
38
+ }
@@ -0,0 +1,168 @@
1
+ // Hermetic OAuth 2.1 authorization server for connector tests: metadata
2
+ // discovery (RFC 8414), dynamic client registration (RFC 7591), PKCE S256
3
+ // authorization-code flow, and refresh with rotation. Loopback http is
4
+ // sanctioned by the SDK (SEP-2207), so no TLS theater is needed.
5
+ 'use strict';
6
+
7
+ const crypto = require('node:crypto');
8
+ const http = require('node:http');
9
+
10
+ function s256(verifier) {
11
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
12
+ }
13
+
14
+ /**
15
+ * @param {object} [opts]
16
+ * @param {Set<string>} [opts.issued] shared with the fake resource server:
17
+ * tokens this AS mints land here; refresh rotates old ones out.
18
+ * @param {number} [opts.expiresIn] access-token lifetime (seconds)
19
+ * @param {boolean} [opts.issParameter] declare RFC 9207
20
+ * (`authorization_response_iss_parameter_supported`) in metadata, which
21
+ * makes the SDK REQUIRE `iss` on the authorization response — the
22
+ * Google shape. The redirect always carries `iss`
23
+ * either way; only the declaration is gated, so non-declaring suites
24
+ * keep their existing behavior.
25
+ * @returns {Promise<{url, issued, authRequests, approve, refreshCount, close}>}
26
+ */
27
+ function startFakeAs({ issued = new Set(), expiresIn = 3600, issParameter = false } = {}) {
28
+ const clients = new Map(); // client_id → registration
29
+ const codes = new Map(); // code → { challenge, clientId, redirectUri, state }
30
+ const refreshTokens = new Map(); // refresh_token → { clientId, accessToken }
31
+ const authRequests = [];
32
+ const state = { refreshCount: 0 };
33
+
34
+ const server = http.createServer(async (req, res) => {
35
+ const url = new URL(req.url, `http://${req.headers.host}`);
36
+ const json = (status, body) => {
37
+ res.writeHead(status, { 'content-type': 'application/json' });
38
+ res.end(JSON.stringify(body));
39
+ };
40
+
41
+ if (req.method === 'GET' && url.pathname.startsWith('/.well-known/oauth-authorization-server')) {
42
+ const issuer = `http://127.0.0.1:${server.address().port}`;
43
+ return json(200, {
44
+ issuer,
45
+ authorization_endpoint: `${issuer}/authorize`,
46
+ token_endpoint: `${issuer}/token`,
47
+ registration_endpoint: `${issuer}/register`,
48
+ response_types_supported: ['code'],
49
+ grant_types_supported: ['authorization_code', 'refresh_token'],
50
+ code_challenge_methods_supported: ['S256'],
51
+ token_endpoint_auth_methods_supported: ['none'],
52
+ ...(issParameter ? { authorization_response_iss_parameter_supported: true } : {}),
53
+ });
54
+ }
55
+
56
+ if (req.method === 'POST' && url.pathname === '/register') {
57
+ let body = '';
58
+ for await (const chunk of req) body += chunk;
59
+ const meta = JSON.parse(body || '{}');
60
+ const clientId = `fake-client-${clients.size + 1}`;
61
+ clients.set(clientId, meta);
62
+ return json(201, {
63
+ client_id: clientId,
64
+ redirect_uris: meta.redirect_uris || [],
65
+ token_endpoint_auth_method: 'none',
66
+ grant_types: meta.grant_types || ['authorization_code'],
67
+ response_types: ['code'],
68
+ });
69
+ }
70
+
71
+ if (req.method === 'GET' && url.pathname === '/authorize') {
72
+ // The "browser leg": record it; tests approve() to mint the redirect.
73
+ authRequests.push(Object.fromEntries(url.searchParams));
74
+ res.writeHead(200, { 'content-type': 'text/html' });
75
+ return res.end('<html><body>fake sign-in page</body></html>');
76
+ }
77
+
78
+ if (req.method === 'POST' && url.pathname === '/token') {
79
+ let body = '';
80
+ for await (const chunk of req) body += chunk;
81
+ const params = new URLSearchParams(body);
82
+ const grant = params.get('grant_type');
83
+
84
+ if (grant === 'authorization_code') {
85
+ const stored = codes.get(params.get('code'));
86
+ if (!stored) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
87
+ codes.delete(params.get('code'));
88
+ if (s256(params.get('code_verifier') || '') !== stored.challenge) {
89
+ return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
90
+ }
91
+ const accessToken = `at-${crypto.randomBytes(12).toString('hex')}`;
92
+ const refreshToken = `rt-${crypto.randomBytes(12).toString('hex')}`;
93
+ issued.add(accessToken);
94
+ refreshTokens.set(refreshToken, { clientId: stored.clientId, accessToken });
95
+ return json(200, {
96
+ access_token: accessToken,
97
+ token_type: 'Bearer',
98
+ expires_in: expiresIn,
99
+ refresh_token: refreshToken,
100
+ });
101
+ }
102
+
103
+ if (grant === 'refresh_token') {
104
+ const stored = refreshTokens.get(params.get('refresh_token'));
105
+ if (!stored) return json(400, { error: 'invalid_grant', error_description: 'unknown refresh token' });
106
+ refreshTokens.delete(params.get('refresh_token'));
107
+ issued.delete(stored.accessToken);
108
+ state.refreshCount += 1;
109
+ const accessToken = `at-${crypto.randomBytes(12).toString('hex')}`;
110
+ const refreshToken = `rt-${crypto.randomBytes(12).toString('hex')}`;
111
+ issued.add(accessToken);
112
+ refreshTokens.set(refreshToken, { clientId: stored.clientId, accessToken });
113
+ return json(200, {
114
+ access_token: accessToken,
115
+ token_type: 'Bearer',
116
+ expires_in: expiresIn,
117
+ refresh_token: refreshToken,
118
+ });
119
+ }
120
+
121
+ return json(400, { error: 'unsupported_grant_type' });
122
+ }
123
+
124
+ json(404, { error: 'not found' });
125
+ });
126
+
127
+ return new Promise((resolve) => {
128
+ server.listen(0, '127.0.0.1', () => {
129
+ resolve({
130
+ url: `http://127.0.0.1:${server.address().port}`,
131
+ issued,
132
+ authRequests,
133
+ get refreshCount() { return state.refreshCount; },
134
+ /**
135
+ * Simulate the human approving a sign-in. Pass the authorization
136
+ * URL the engine handed out (the SDK never fetches /authorize
137
+ * itself — that is the browser's job); with no argument, the last
138
+ * browser-fetched /authorize request is used instead.
139
+ */
140
+ approve(authorizationUrl) {
141
+ const reqParams = authorizationUrl
142
+ ? Object.fromEntries(new URL(authorizationUrl).searchParams)
143
+ : authRequests[authRequests.length - 1];
144
+ if (!reqParams) throw new Error('no authorization request to approve');
145
+ const code = `code-${crypto.randomBytes(8).toString('hex')}`;
146
+ codes.set(code, {
147
+ challenge: reqParams.code_challenge,
148
+ clientId: reqParams.client_id,
149
+ redirectUri: reqParams.redirect_uri,
150
+ state: reqParams.state,
151
+ });
152
+ const redirect = new URL(reqParams.redirect_uri);
153
+ redirect.searchParams.set('code', code);
154
+ if (reqParams.state) redirect.searchParams.set('state', reqParams.state);
155
+ // RFC 9207: a real declaring AS (Google) always stamps the response
156
+ // with its issuer; send it unconditionally — clients validate it
157
+ // only when the metadata declares support.
158
+ const issuer = `http://127.0.0.1:${server.address().port}`;
159
+ redirect.searchParams.set('iss', issuer);
160
+ return { redirectUrl: redirect.toString(), code, state: reqParams.state, iss: issuer };
161
+ },
162
+ close: () => new Promise((r) => server.close(r)),
163
+ });
164
+ });
165
+ });
166
+ }
167
+
168
+ module.exports = { startFakeAs };
@@ -0,0 +1,137 @@
1
+ // Hermetic streamable-HTTP MCP server: JSON-RPC over POST with plain JSON
2
+ // responses (the spec's stateless mode). Optionally OAuth-protected — a
3
+ // request without a valid bearer gets 401 + the RFC 9728 pointer at the
4
+ // fake AS, which is exactly what drives the SDK's discovery.
5
+ 'use strict';
6
+
7
+ const http = require('node:http');
8
+
9
+ const DEFAULT_TOOLS = [
10
+ {
11
+ name: 'echo',
12
+ description: 'Echo the message back',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: { message: { type: 'string', description: 'What to echo' } },
16
+ required: ['message'],
17
+ },
18
+ },
19
+ ];
20
+
21
+ /**
22
+ * @param {object} [opts]
23
+ * @param {boolean} [opts.requireAuth]
24
+ * @param {string} [opts.asUrl] the fake AS (required with requireAuth)
25
+ * @param {Set<string>} [opts.issued] tokens the AS minted (shared set)
26
+ * @param {Array} [opts.tools]
27
+ * @param {(name, args) => object} [opts.onCall] → { text, isError? }
28
+ * @param {boolean} [opts.failCalls] every tools/call errors (tool-error path)
29
+ */
30
+ function startFakeMcpHttp({
31
+ requireAuth = false, asUrl, issued = new Set(), tools = DEFAULT_TOOLS,
32
+ onCall, failCalls = false,
33
+ } = {}) {
34
+ const seen = { calls: [], initializes: 0 };
35
+
36
+ const server = http.createServer(async (req, res) => {
37
+ const url = new URL(req.url, `http://${req.headers.host}`);
38
+ const base = `http://127.0.0.1:${server.address().port}`;
39
+ const json = (status, body, headers = {}) => {
40
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
41
+ res.end(JSON.stringify(body));
42
+ };
43
+
44
+ if (req.method === 'GET' && url.pathname.startsWith('/.well-known/oauth-protected-resource')) {
45
+ return json(200, {
46
+ resource: base,
47
+ authorization_servers: [asUrl],
48
+ });
49
+ }
50
+
51
+ if (req.method === 'GET') {
52
+ // No standing SSE stream in the fake — the spec allows 405.
53
+ res.writeHead(405, { allow: 'POST' });
54
+ return res.end();
55
+ }
56
+
57
+ if (req.method !== 'POST') {
58
+ res.writeHead(405);
59
+ return res.end();
60
+ }
61
+
62
+ if (requireAuth) {
63
+ const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
64
+ if (!m || !issued.has(m[1])) {
65
+ return json(401, { error: 'unauthorized' }, {
66
+ 'www-authenticate': `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`,
67
+ });
68
+ }
69
+ }
70
+
71
+ let body = '';
72
+ for await (const chunk of req) body += chunk;
73
+ let msg;
74
+ try {
75
+ msg = JSON.parse(body);
76
+ } catch {
77
+ return json(400, { error: 'bad json' });
78
+ }
79
+
80
+ // Notifications get a bare 202 (spec: no body).
81
+ if (msg.id === undefined) {
82
+ res.writeHead(202);
83
+ return res.end();
84
+ }
85
+
86
+ const reply = (result) => json(200, { jsonrpc: '2.0', id: msg.id, result });
87
+ const rpcError = (code, message) => json(200, {
88
+ jsonrpc: '2.0', id: msg.id, error: { code, message },
89
+ });
90
+
91
+ switch (msg.method) {
92
+ case 'initialize':
93
+ seen.initializes += 1;
94
+ return reply({
95
+ protocolVersion: msg.params?.protocolVersion || '2025-06-18',
96
+ capabilities: { tools: {} },
97
+ serverInfo: { name: 'fake-mcp-http', version: '1.0.0' },
98
+ });
99
+ case 'ping':
100
+ return reply({});
101
+ case 'tools/list':
102
+ return reply({ tools });
103
+ case 'tools/call': {
104
+ seen.calls.push({ name: msg.params?.name, args: msg.params?.arguments });
105
+ if (failCalls) {
106
+ return reply({
107
+ content: [{ type: 'text', text: 'the service rejected this request' }],
108
+ isError: true,
109
+ });
110
+ }
111
+ const out = onCall
112
+ ? onCall(msg.params?.name, msg.params?.arguments || {})
113
+ : { text: `echo: ${msg.params?.arguments?.message ?? ''}` };
114
+ return reply({
115
+ content: [{ type: 'text', text: out.text }],
116
+ isError: Boolean(out.isError),
117
+ });
118
+ }
119
+ default:
120
+ // Unknown methods (incl. the 2026 discover probe) → legacy signal.
121
+ return rpcError(-32601, `method not found: ${msg.method}`);
122
+ }
123
+ });
124
+
125
+ return new Promise((resolve) => {
126
+ server.listen(0, '127.0.0.1', () => {
127
+ resolve({
128
+ url: `http://127.0.0.1:${server.address().port}`,
129
+ seen,
130
+ issued,
131
+ close: () => new Promise((r) => server.close(r)),
132
+ });
133
+ });
134
+ });
135
+ }
136
+
137
+ module.exports = { startFakeMcpHttp, DEFAULT_TOOLS };