@ferrlabs/mcp-core 6.1.0 → 6.2.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/dist/__tests__/api-client.test.js +23 -1
- package/dist/api-client.d.ts +3 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +13 -0
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/transports/http.d.ts +14 -0
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +41 -1
- package/package.json +2 -2
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { apiRequest } from '../api-client.js';
|
|
2
|
+
import { apiRequest, UnauthorizedError } from '../api-client.js';
|
|
3
3
|
const mockFetch = vi.fn();
|
|
4
4
|
vi.stubGlobal('fetch', mockFetch);
|
|
5
|
+
const clearPersistedTokenMock = vi.fn();
|
|
6
|
+
const clearTokenCacheMock = vi.fn();
|
|
7
|
+
vi.mock('../auth/persistence.js', () => ({
|
|
8
|
+
clearPersistedToken: () => clearPersistedTokenMock(),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('../auth/index.js', () => ({
|
|
11
|
+
clearTokenCache: () => clearTokenCacheMock(),
|
|
12
|
+
}));
|
|
5
13
|
function makeResponse(body, status = 200) {
|
|
6
14
|
const text = body === undefined ? '' : JSON.stringify(body);
|
|
7
15
|
return {
|
|
@@ -14,6 +22,8 @@ function makeResponse(body, status = 200) {
|
|
|
14
22
|
describe('apiRequest', () => {
|
|
15
23
|
beforeEach(() => {
|
|
16
24
|
mockFetch.mockReset();
|
|
25
|
+
clearPersistedTokenMock.mockReset();
|
|
26
|
+
clearTokenCacheMock.mockReset();
|
|
17
27
|
});
|
|
18
28
|
it('makes a GET request and returns parsed JSON', async () => {
|
|
19
29
|
mockFetch.mockResolvedValue(makeResponse({ status: 'ok' }));
|
|
@@ -46,4 +56,16 @@ describe('apiRequest', () => {
|
|
|
46
56
|
const [, init] = mockFetch.mock.calls[0];
|
|
47
57
|
expect(init.body).toBe(JSON.stringify({ name: 'ci', scopes: ['*'] }));
|
|
48
58
|
});
|
|
59
|
+
it('clears persisted token and cache on 401 with a token', async () => {
|
|
60
|
+
mockFetch.mockResolvedValue(makeResponse({ error: 'unauthorized' }, 401));
|
|
61
|
+
await expect(apiRequest('/v1/orgs', { token: 'stale' })).rejects.toBeInstanceOf(UnauthorizedError);
|
|
62
|
+
expect(clearPersistedTokenMock).toHaveBeenCalledOnce();
|
|
63
|
+
expect(clearTokenCacheMock).toHaveBeenCalledOnce();
|
|
64
|
+
});
|
|
65
|
+
it('does not clear persisted token on 401 without a token', async () => {
|
|
66
|
+
mockFetch.mockResolvedValue(makeResponse({ error: 'unauthorized' }, 401));
|
|
67
|
+
await expect(apiRequest('/v1/orgs')).rejects.toThrow('unauthorized');
|
|
68
|
+
expect(clearPersistedTokenMock).not.toHaveBeenCalled();
|
|
69
|
+
expect(clearTokenCacheMock).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
49
71
|
});
|
package/dist/api-client.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ interface RequestOptions {
|
|
|
9
9
|
*/
|
|
10
10
|
baseUrl?: string;
|
|
11
11
|
}
|
|
12
|
+
export declare class UnauthorizedError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
12
15
|
export declare function apiRequest<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
13
16
|
export {};
|
|
14
17
|
//# sourceMappingURL=api-client.d.ts.map
|
package/dist/api-client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAKA,UAAU,cAAc;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,OAAO,EAAE,MAAM;CAI5B;AAED,wBAAsB,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC,CAmD1F"}
|
package/dist/api-client.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { clearPersistedToken } from './auth/persistence.js';
|
|
2
|
+
import { clearTokenCache } from './auth/index.js';
|
|
1
3
|
const DEFAULT_API_URL = process.env.API_URL ?? 'https://api.ferrlabs.com';
|
|
4
|
+
export class UnauthorizedError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'UnauthorizedError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
2
10
|
export async function apiRequest(path, options = {}) {
|
|
3
11
|
const { method = 'GET', body, token, baseUrl } = options;
|
|
4
12
|
const base = baseUrl ?? DEFAULT_API_URL;
|
|
@@ -18,6 +26,11 @@ export async function apiRequest(path, options = {}) {
|
|
|
18
26
|
if (res.status === 204) {
|
|
19
27
|
return undefined;
|
|
20
28
|
}
|
|
29
|
+
if (res.status === 401 && token) {
|
|
30
|
+
await clearPersistedToken();
|
|
31
|
+
clearTokenCache();
|
|
32
|
+
throw new UnauthorizedError('Stored token rejected by the FerrLabs API (likely revoked, expired, or issued under an incompatible format). The token has been cleared — retry the call to trigger a fresh OAuth login.');
|
|
33
|
+
}
|
|
21
34
|
const raw = await res.text();
|
|
22
35
|
let data = undefined;
|
|
23
36
|
if (raw) {
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare function getToken(): Promise<string>;
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function clearTokenCache(): void;
|
|
3
|
+
export declare const resetTokenCacheForTesting: typeof clearTokenCache;
|
|
3
4
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/auth/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAOA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAuChD;AAED,wBAAgB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAOA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAuChD;AAED,wBAAgB,eAAe,IAAI,IAAI,CAGtC;AAED,eAAO,MAAM,yBAAyB,wBAAkB,CAAC"}
|
package/dist/auth/index.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { runMcp, type RunMcpOptions } from './runner.js';
|
|
2
|
-
export { apiRequest } from './api-client.js';
|
|
3
|
-
export { getToken } from './auth/index.js';
|
|
2
|
+
export { apiRequest, UnauthorizedError } from './api-client.js';
|
|
3
|
+
export { getToken, clearTokenCache } from './auth/index.js';
|
|
4
4
|
export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';
|
|
5
5
|
export type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,YAAY,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { runMcp } from './runner.js';
|
|
2
|
-
export { apiRequest } from './api-client.js';
|
|
3
|
-
export { getToken } from './auth/index.js';
|
|
2
|
+
export { apiRequest, UnauthorizedError } from './api-client.js';
|
|
3
|
+
export { getToken, clearTokenCache } from './auth/index.js';
|
|
4
4
|
export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';
|
|
@@ -4,6 +4,20 @@ export interface HttpServerOptions {
|
|
|
4
4
|
host?: string;
|
|
5
5
|
stateless?: boolean;
|
|
6
6
|
createServer: () => McpServer;
|
|
7
|
+
/**
|
|
8
|
+
* URL of the OAuth 2.0 Authorization Server users authenticate against.
|
|
9
|
+
* Surfaced to MCP clients via /.well-known/oauth-protected-resource so
|
|
10
|
+
* they can drive the standard PKCE flow when no Bearer is present.
|
|
11
|
+
*
|
|
12
|
+
* Must be the host that serves the OIDC / OAuth 2.0 discovery metadata
|
|
13
|
+
* (i.e. `<host>/.well-known/openid-configuration` or
|
|
14
|
+
* `<host>/.well-known/oauth-authorization-server`). For FerrLabs that's
|
|
15
|
+
* `api.ferrlabs.com` — `auth.ferrlabs.com` is the user-facing login SPA
|
|
16
|
+
* but doesn't serve the metadata documents.
|
|
17
|
+
*
|
|
18
|
+
* Defaults to `FERRLABS_AUTH_URL` env or `https://api.ferrlabs.com`.
|
|
19
|
+
*/
|
|
20
|
+
authorizationServer?: string;
|
|
7
21
|
}
|
|
8
22
|
export declare function startHttpServer(opts: HttpServerOptions): Promise<void>;
|
|
9
23
|
//# sourceMappingURL=http.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiBzE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,EAAE,MAAM,SAAS,CAAC;IAC9B;;;;;;;;;;;;OAYG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuH5E"}
|
package/dist/transports/http.js
CHANGED
|
@@ -9,6 +9,11 @@ function extractBearer(req) {
|
|
|
9
9
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
|
10
10
|
return match?.[1];
|
|
11
11
|
}
|
|
12
|
+
function publicOrigin(req) {
|
|
13
|
+
const proto = req.headers['x-forwarded-proto']?.split(',')[0]?.trim() ?? 'https';
|
|
14
|
+
const host = req.headers['x-forwarded-host'] ?? req.headers.host ?? '';
|
|
15
|
+
return `${proto}://${host}`;
|
|
16
|
+
}
|
|
12
17
|
async function readJsonBody(req) {
|
|
13
18
|
if (req.method !== 'POST')
|
|
14
19
|
return undefined;
|
|
@@ -31,8 +36,40 @@ async function readJsonBody(req) {
|
|
|
31
36
|
}
|
|
32
37
|
export async function startHttpServer(opts) {
|
|
33
38
|
const { port, host = '0.0.0.0', stateless = true } = opts;
|
|
39
|
+
const authServer = opts.authorizationServer ?? process.env.FERRLABS_AUTH_URL ?? 'https://api.ferrlabs.com';
|
|
34
40
|
const transports = new Map();
|
|
41
|
+
function sendUnauthorized(req, res) {
|
|
42
|
+
const origin = publicOrigin(req);
|
|
43
|
+
const metadataUrl = `${origin}/.well-known/oauth-protected-resource`;
|
|
44
|
+
res.writeHead(401, {
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
'WWW-Authenticate': `Bearer resource_metadata="${metadataUrl}"`,
|
|
47
|
+
});
|
|
48
|
+
res.end(JSON.stringify({
|
|
49
|
+
jsonrpc: '2.0',
|
|
50
|
+
error: {
|
|
51
|
+
code: -32001,
|
|
52
|
+
message: 'Missing or invalid bearer token. The MCP client should fetch the OAuth protected-resource metadata and run the authorization code + PKCE flow.',
|
|
53
|
+
},
|
|
54
|
+
id: null,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
function sendResourceMetadata(req, res) {
|
|
58
|
+
const origin = publicOrigin(req);
|
|
59
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
60
|
+
res.end(JSON.stringify({
|
|
61
|
+
resource: origin,
|
|
62
|
+
authorization_servers: [authServer],
|
|
63
|
+
bearer_methods_supported: ['header'],
|
|
64
|
+
resource_documentation: 'https://ferrlabs.com',
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
35
67
|
async function handleMcpRequest(req, res) {
|
|
68
|
+
const bearerToken = extractBearer(req);
|
|
69
|
+
if (!bearerToken) {
|
|
70
|
+
sendUnauthorized(req, res);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
36
73
|
const sessionId = req.headers['mcp-session-id'] ?? undefined;
|
|
37
74
|
let transport = sessionId ? transports.get(sessionId) : undefined;
|
|
38
75
|
if (sessionId && !transport && !stateless) {
|
|
@@ -67,7 +104,6 @@ export async function startHttpServer(opts) {
|
|
|
67
104
|
await server.connect(transport);
|
|
68
105
|
}
|
|
69
106
|
const body = await readJsonBody(req);
|
|
70
|
-
const bearerToken = extractBearer(req);
|
|
71
107
|
await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
|
|
72
108
|
}
|
|
73
109
|
const httpServer = createHttpServer((req, res) => {
|
|
@@ -77,6 +113,10 @@ export async function startHttpServer(opts) {
|
|
|
77
113
|
res.end(JSON.stringify({ status: 'ok', service: 'ferrlabs-mcp' }));
|
|
78
114
|
return;
|
|
79
115
|
}
|
|
116
|
+
if (url === '/.well-known/oauth-protected-resource') {
|
|
117
|
+
sendResourceMetadata(req, res);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
80
120
|
if (url === '/mcp' || url.startsWith('/mcp?')) {
|
|
81
121
|
handleMcpRequest(req, res).catch((err) => {
|
|
82
122
|
if (!res.headersSent) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferrlabs/mcp-core",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "Shared core for FerrLabs MCP servers
|
|
3
|
+
"version": "6.2.2",
|
|
4
|
+
"description": "Shared core for FerrLabs MCP servers — auth, transports (stdio + Streamable HTTP), api-client, runner",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|