mcpico 0.1.1 → 0.2.0
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/README.md +123 -2
- package/dist/auth-types.d.ts +54 -0
- package/dist/auth-types.js +9 -0
- package/dist/auth-types.js.map +1 -0
- package/dist/auth.d.ts +38 -0
- package/dist/auth.js +122 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js.map +1 -1
- package/dist/discoverer.d.ts +2 -1
- package/dist/discoverer.js +15 -3
- package/dist/discoverer.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/oauth-provider.d.ts +19 -0
- package/dist/oauth-provider.js +79 -0
- package/dist/oauth-provider.js.map +1 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +132 -57
- package/dist/server.js.map +1 -1
- package/dist/token-store.d.ts +11 -0
- package/dist/token-store.js +94 -0
- package/dist/token-store.js.map +1 -0
- package/package.json +2 -1
- package/src/auth-types.ts +71 -0
- package/src/auth.test.ts +218 -0
- package/src/auth.ts +159 -0
- package/src/config.ts +14 -0
- package/src/discoverer.ts +25 -5
- package/src/help.test.ts +40 -0
- package/src/index.ts +1 -1
- package/src/oauth-provider.ts +110 -0
- package/src/server.test.ts +324 -0
- package/src/server.ts +165 -75
- package/src/token-store.test.ts +154 -0
- package/src/token-store.ts +109 -0
- package/vitest.config.ts +7 -4
package/src/auth.test.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auth utilities.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
resolveEnvVars,
|
|
7
|
+
resolveUpstreamAuth,
|
|
8
|
+
resolveListenAuth,
|
|
9
|
+
upstreamAuthHeaders,
|
|
10
|
+
extractBearerToken,
|
|
11
|
+
validateBearerToken,
|
|
12
|
+
sendUnauthorized,
|
|
13
|
+
} from "./auth.js";
|
|
14
|
+
import type {
|
|
15
|
+
BearerAuth,
|
|
16
|
+
HeaderAuth,
|
|
17
|
+
OAuthClientCredentials,
|
|
18
|
+
ListenBearerAuth,
|
|
19
|
+
} from "./auth-types.js";
|
|
20
|
+
|
|
21
|
+
describe("resolveEnvVars", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.stubEnv("TEST_TOKEN", "secret123");
|
|
24
|
+
vi.stubEnv("API_KEY", "key-abc");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllEnvs();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("replaces ${VAR} patterns with env values", () => {
|
|
32
|
+
expect(resolveEnvVars("Bearer ${TEST_TOKEN}")).toBe("Bearer secret123");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("replaces multiple env vars in one string", () => {
|
|
36
|
+
expect(resolveEnvVars("${API_KEY}:${TEST_TOKEN}")).toBe("key-abc:secret123");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns string unchanged if no env vars", () => {
|
|
40
|
+
expect(resolveEnvVars("plain-value")).toBe("plain-value");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("throws if referenced env var is not set", () => {
|
|
44
|
+
expect(() => resolveEnvVars("${MISSING}")).toThrow(
|
|
45
|
+
'Environment variable "MISSING" is not set'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("resolveUpstreamAuth", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.stubEnv("BEARER_TOKEN", "bearer-token-123");
|
|
53
|
+
vi.stubEnv("CUSTOM_KEY", "custom-key-456");
|
|
54
|
+
vi.stubEnv("OAUTH_CLIENT_ID", "client-id");
|
|
55
|
+
vi.stubEnv("OAUTH_CLIENT_SECRET", "client-secret");
|
|
56
|
+
vi.stubEnv("OAUTH_TOKEN_URL", "https://auth.example.com/token");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.unstubAllEnvs();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("resolves bearer auth", () => {
|
|
64
|
+
const auth: BearerAuth = { type: "bearer", token: "${BEARER_TOKEN}" };
|
|
65
|
+
const resolved = resolveUpstreamAuth(auth);
|
|
66
|
+
expect(resolved).toEqual({
|
|
67
|
+
type: "bearer",
|
|
68
|
+
token: "bearer-token-123",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("resolves header auth", () => {
|
|
73
|
+
const auth: HeaderAuth = {
|
|
74
|
+
type: "header",
|
|
75
|
+
name: "X-API-Key",
|
|
76
|
+
value: "${CUSTOM_KEY}",
|
|
77
|
+
};
|
|
78
|
+
const resolved = resolveUpstreamAuth(auth);
|
|
79
|
+
expect(resolved).toEqual({
|
|
80
|
+
type: "header",
|
|
81
|
+
name: "X-API-Key",
|
|
82
|
+
value: "custom-key-456",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("resolves oauth auth", () => {
|
|
87
|
+
const auth: OAuthClientCredentials = {
|
|
88
|
+
type: "oauth",
|
|
89
|
+
grant_type: "client_credentials",
|
|
90
|
+
client_id: "${OAUTH_CLIENT_ID}",
|
|
91
|
+
client_secret: "${OAUTH_CLIENT_SECRET}",
|
|
92
|
+
token_url: "${OAUTH_TOKEN_URL}",
|
|
93
|
+
};
|
|
94
|
+
const resolved = resolveUpstreamAuth(auth);
|
|
95
|
+
expect(resolved).toEqual({
|
|
96
|
+
type: "oauth",
|
|
97
|
+
grant_type: "client_credentials",
|
|
98
|
+
client_id: "client-id",
|
|
99
|
+
client_secret: "client-secret",
|
|
100
|
+
token_url: "https://auth.example.com/token",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("resolveListenAuth", () => {
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
vi.stubEnv("MCPICO_KEY", "mcplico-key-789");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
vi.unstubAllEnvs();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("resolves listen bearer auth", () => {
|
|
115
|
+
const auth: ListenBearerAuth = { type: "bearer", token: "${MCPICO_KEY}" };
|
|
116
|
+
const resolved = resolveListenAuth(auth);
|
|
117
|
+
expect(resolved).toEqual({
|
|
118
|
+
type: "bearer",
|
|
119
|
+
token: "mcplico-key-789",
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("upstreamAuthHeaders", () => {
|
|
125
|
+
it("returns Authorization header for bearer auth", () => {
|
|
126
|
+
const headers = upstreamAuthHeaders({
|
|
127
|
+
type: "bearer",
|
|
128
|
+
token: "my-token",
|
|
129
|
+
});
|
|
130
|
+
expect(headers).toEqual({ Authorization: "Bearer my-token" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns custom header for header auth", () => {
|
|
134
|
+
const headers = upstreamAuthHeaders({
|
|
135
|
+
type: "header",
|
|
136
|
+
name: "X-API-Key",
|
|
137
|
+
value: "key-123",
|
|
138
|
+
});
|
|
139
|
+
expect(headers).toEqual({ "X-API-Key": "key-123" });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns empty object for oauth auth (handled by SDK)", () => {
|
|
143
|
+
const headers = upstreamAuthHeaders({
|
|
144
|
+
type: "oauth",
|
|
145
|
+
grant_type: "client_credentials",
|
|
146
|
+
client_id: "id",
|
|
147
|
+
client_secret: "secret",
|
|
148
|
+
token_url: "https://auth.example.com/token",
|
|
149
|
+
});
|
|
150
|
+
expect(headers).toEqual({});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("extractBearerToken", () => {
|
|
155
|
+
it("extracts token from Authorization header", () => {
|
|
156
|
+
const req = { headers: { authorization: "Bearer my-token" } } as any;
|
|
157
|
+
expect(extractBearerToken(req)).toBe("my-token");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("is case-insensitive for Bearer prefix", () => {
|
|
161
|
+
const req = { headers: { authorization: "bearer my-token" } } as any;
|
|
162
|
+
expect(extractBearerToken(req)).toBe("my-token");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns undefined if no Authorization header", () => {
|
|
166
|
+
const req = { headers: {} } as any;
|
|
167
|
+
expect(extractBearerToken(req)).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns undefined if header is not Bearer", () => {
|
|
171
|
+
const req = { headers: { authorization: "Basic dXNlcjpwYXNz" } } as any;
|
|
172
|
+
expect(extractBearerToken(req)).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns undefined if Authorization is empty string", () => {
|
|
176
|
+
const req = { headers: { authorization: "" } } as any;
|
|
177
|
+
expect(extractBearerToken(req)).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("validateBearerToken", () => {
|
|
182
|
+
it("returns true when tokens match", () => {
|
|
183
|
+
expect(validateBearerToken("secret", "secret")).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns false when tokens differ", () => {
|
|
187
|
+
expect(validateBearerToken("wrong", "secret")).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("returns false when provided token is undefined", () => {
|
|
191
|
+
expect(validateBearerToken(undefined, "secret")).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("returns false when tokens have different length", () => {
|
|
195
|
+
expect(validateBearerToken("short", "longer-token")).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("is case-sensitive", () => {
|
|
199
|
+
expect(validateBearerToken("Secret", "secret")).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("sendUnauthorized", () => {
|
|
204
|
+
it("sends 401 with WWW-Authenticate header", () => {
|
|
205
|
+
const res = {
|
|
206
|
+
writeHead: vi.fn(),
|
|
207
|
+
end: vi.fn(),
|
|
208
|
+
} as any;
|
|
209
|
+
|
|
210
|
+
sendUnauthorized(res);
|
|
211
|
+
|
|
212
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, {
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
"WWW-Authenticate": 'Bearer realm="mcplico"',
|
|
215
|
+
});
|
|
216
|
+
expect(res.end).toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
});
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth utilities: env var resolution, header generation, listen token validation.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
+
import type {
|
|
6
|
+
UpstreamAuth,
|
|
7
|
+
ListenAuth,
|
|
8
|
+
ResolvedUpstreamAuth,
|
|
9
|
+
ResolvedListenAuth,
|
|
10
|
+
BearerAuth,
|
|
11
|
+
HeaderAuth,
|
|
12
|
+
OAuthClientCredentials,
|
|
13
|
+
} from "./auth-types.js";
|
|
14
|
+
|
|
15
|
+
// ── Env var interpolation ──
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replace ${VAR} patterns with process.env values.
|
|
19
|
+
* Throws if a referenced env var is not set.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveEnvVars(value: string): string {
|
|
22
|
+
return value.replace(/\$\{(\w+)\}/g, (_match, varName: string) => {
|
|
23
|
+
const envValue = process.env[varName];
|
|
24
|
+
if (envValue === undefined) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Environment variable "${varName}" is not set. ` +
|
|
27
|
+
`Referenced in auth config. Set it or remove the auth block.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return envValue;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Auth resolution ──
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve an UpstreamAuth config to a ResolvedUpstreamAuth with env vars interpolated.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveUpstreamAuth(
|
|
40
|
+
auth: UpstreamAuth
|
|
41
|
+
): ResolvedUpstreamAuth {
|
|
42
|
+
switch (auth.type) {
|
|
43
|
+
case "bearer":
|
|
44
|
+
return {
|
|
45
|
+
type: "bearer",
|
|
46
|
+
token: resolveEnvVars(auth.token),
|
|
47
|
+
} as ResolvedUpstreamAuth;
|
|
48
|
+
|
|
49
|
+
case "header":
|
|
50
|
+
return {
|
|
51
|
+
type: "header",
|
|
52
|
+
name: resolveEnvVars(auth.name),
|
|
53
|
+
value: resolveEnvVars(auth.value),
|
|
54
|
+
} as ResolvedUpstreamAuth;
|
|
55
|
+
|
|
56
|
+
case "oauth":
|
|
57
|
+
// OAuth credentials are resolved at request time by the provider
|
|
58
|
+
return {
|
|
59
|
+
type: "oauth",
|
|
60
|
+
grant_type: auth.grant_type,
|
|
61
|
+
client_id: resolveEnvVars(auth.client_id),
|
|
62
|
+
client_secret: resolveEnvVars(auth.client_secret),
|
|
63
|
+
token_url: resolveEnvVars(auth.token_url),
|
|
64
|
+
scopes: auth.scopes,
|
|
65
|
+
authorization_server_url: auth.authorization_server_url
|
|
66
|
+
? resolveEnvVars(auth.authorization_server_url)
|
|
67
|
+
: undefined,
|
|
68
|
+
} as ResolvedUpstreamAuth;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a ListenAuth config to ResolvedListenAuth.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveListenAuth(
|
|
76
|
+
auth: ListenAuth
|
|
77
|
+
): ResolvedListenAuth {
|
|
78
|
+
return {
|
|
79
|
+
type: "bearer",
|
|
80
|
+
token: resolveEnvVars(auth.token),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Header generation for upstream requests ──
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generate HTTP headers from a resolved upstream auth config.
|
|
88
|
+
* For bearer/header types, returns headers to attach to requests.
|
|
89
|
+
* For OAuth, returns empty — the SDK authProvider handles it.
|
|
90
|
+
*/
|
|
91
|
+
export function upstreamAuthHeaders(
|
|
92
|
+
auth: ResolvedUpstreamAuth
|
|
93
|
+
): Record<string, string> {
|
|
94
|
+
if (auth.type === "bearer") {
|
|
95
|
+
return { Authorization: `Bearer ${auth.token}` };
|
|
96
|
+
}
|
|
97
|
+
if (auth.type === "header") {
|
|
98
|
+
return { [auth.name]: auth.value };
|
|
99
|
+
}
|
|
100
|
+
// OAuth is handled by the MCP SDK's authProvider — no static headers
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Listen endpoint auth middleware ──
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extract bearer token from an Authorization header.
|
|
108
|
+
* Returns undefined if no Bearer token found.
|
|
109
|
+
*/
|
|
110
|
+
export function extractBearerToken(req: IncomingMessage): string | undefined {
|
|
111
|
+
const auth = req.headers.authorization;
|
|
112
|
+
if (!auth) return undefined;
|
|
113
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
114
|
+
return match ? match[1] : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate a bearer token against the configured token.
|
|
119
|
+
* Returns true if token is valid, false otherwise.
|
|
120
|
+
*/
|
|
121
|
+
export function validateBearerToken(
|
|
122
|
+
providedToken: string | undefined,
|
|
123
|
+
expectedToken: string
|
|
124
|
+
): boolean {
|
|
125
|
+
if (!providedToken) return false;
|
|
126
|
+
// Constant-time comparison to prevent timing attacks
|
|
127
|
+
return timingSafeEqual(providedToken, expectedToken);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Send a 401 Unauthorized response with a WWW-Authenticate header.
|
|
132
|
+
*/
|
|
133
|
+
export function sendUnauthorized(res: ServerResponse): void {
|
|
134
|
+
res.writeHead(401, {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"WWW-Authenticate": 'Bearer realm="mcplico"',
|
|
137
|
+
});
|
|
138
|
+
res.end(JSON.stringify({ error: "Unauthorized", message: "Bearer token required" }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Constant-time string comparison ──
|
|
142
|
+
|
|
143
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
144
|
+
if (a.length !== b.length) {
|
|
145
|
+
// Still compare to avoid leaking length via timing
|
|
146
|
+
const maxLen = Math.max(a.length, b.length);
|
|
147
|
+
let result = 0;
|
|
148
|
+
for (let i = 0; i < maxLen; i++) {
|
|
149
|
+
result |= a.charCodeAt(i % a.length) ^ b.charCodeAt(i % b.length);
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let result = 0;
|
|
155
|
+
for (let i = 0; i < a.length; i++) {
|
|
156
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
157
|
+
}
|
|
158
|
+
return result === 0;
|
|
159
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transport configuration for connecting to an upstream MCP server.
|
|
3
3
|
*/
|
|
4
|
+
import type { UpstreamAuth, ListenAuth } from "./auth-types.js";
|
|
5
|
+
|
|
6
|
+
|
|
4
7
|
export type TransportConfig =
|
|
5
8
|
| {
|
|
6
9
|
type: "stdio";
|
|
@@ -25,6 +28,8 @@ export interface ServerConfig {
|
|
|
25
28
|
transport: TransportConfig;
|
|
26
29
|
/** Connection timeout in milliseconds (default: 30000) */
|
|
27
30
|
connectTimeoutMs?: number;
|
|
31
|
+
/** Authentication config for this upstream server */
|
|
32
|
+
auth?: UpstreamAuth;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -34,6 +39,13 @@ export interface GroupOverrides {
|
|
|
34
39
|
[groupName: string]: string[];
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Transport to expose MCPico itself to clients.
|
|
44
|
+
*/
|
|
45
|
+
export type ListenConfig =
|
|
46
|
+
| { type: "stdio" }
|
|
47
|
+
| { type: "sse"; port: number; host?: string; auth?: ListenAuth };
|
|
48
|
+
|
|
37
49
|
/**
|
|
38
50
|
* Full MCPico configuration.
|
|
39
51
|
*/
|
|
@@ -44,6 +56,8 @@ export interface MCPicoConfig {
|
|
|
44
56
|
separator?: string;
|
|
45
57
|
/** Explicit group overrides — tools not listed here are auto-grouped */
|
|
46
58
|
groups?: GroupOverrides;
|
|
59
|
+
/** How MCPico exposes itself to clients (default: stdio) */
|
|
60
|
+
listen?: ListenConfig;
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
/**
|
package/src/discoverer.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
4
|
import type { TransportConfig } from "./config.js";
|
|
5
|
+
import type { ResolvedUpstreamAuth } from "./auth-types.js";
|
|
6
|
+
import { upstreamAuthHeaders } from "./auth.js";
|
|
7
|
+
import { createClientCredentialsProvider } from "./oauth-provider.js";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Tool metadata from an upstream MCP server.
|
|
@@ -51,7 +54,8 @@ export interface DiscoveredServer {
|
|
|
51
54
|
export async function discoverServer(
|
|
52
55
|
name: string,
|
|
53
56
|
transportConfig: TransportConfig,
|
|
54
|
-
connectTimeoutMs: number = 30_000
|
|
57
|
+
connectTimeoutMs: number = 30_000,
|
|
58
|
+
auth?: ResolvedUpstreamAuth
|
|
55
59
|
): Promise<DiscoveredServer> {
|
|
56
60
|
let transport;
|
|
57
61
|
|
|
@@ -63,15 +67,31 @@ export async function discoverServer(
|
|
|
63
67
|
cwd: transportConfig.cwd,
|
|
64
68
|
});
|
|
65
69
|
} else if (transportConfig.type === "sse") {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const url = new URL(transportConfig.url);
|
|
71
|
+
const opts: {
|
|
72
|
+
requestInit?: { headers: Record<string, string> };
|
|
73
|
+
authProvider?: ReturnType<typeof createClientCredentialsProvider>;
|
|
74
|
+
} = {};
|
|
75
|
+
|
|
76
|
+
if (auth) {
|
|
77
|
+
if (auth.type === "oauth") {
|
|
78
|
+
opts.authProvider = createClientCredentialsProvider(
|
|
79
|
+
auth.client_id,
|
|
80
|
+
auth.client_secret,
|
|
81
|
+
transportConfig.url
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
opts.requestInit = { headers: upstreamAuthHeaders(auth) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
transport = new StreamableHTTPClientTransport(url, opts);
|
|
69
89
|
} else {
|
|
70
90
|
throw new Error(`Unsupported transport type: ${(transportConfig as TransportConfig).type}`);
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
const client = new Client(
|
|
74
|
-
{ name: `MCPico-${name}`, version: "0.
|
|
94
|
+
{ name: `MCPico-${name}`, version: "0.2.0" },
|
|
75
95
|
{ capabilities: {} }
|
|
76
96
|
);
|
|
77
97
|
|
package/src/help.test.ts
CHANGED
|
@@ -194,4 +194,44 @@ describe("generateHelpText", () => {
|
|
|
194
194
|
expect(text).toContain(tool.name);
|
|
195
195
|
// Should not crash
|
|
196
196
|
});
|
|
197
|
+
|
|
198
|
+
it("handles non-standard property types gracefully", () => {
|
|
199
|
+
const tool = makeTool({
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: {
|
|
203
|
+
weird: { type: null, description: "Unknown type" },
|
|
204
|
+
},
|
|
205
|
+
required: ["weird"],
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const text = generateHelpText(makeGroup({ tools: [tool] }));
|
|
209
|
+
// Should not crash — fallback to "any"
|
|
210
|
+
expect(text).toContain("(any");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("falls back to bare subcommand for very long examples", () => {
|
|
214
|
+
// Three required params with long names → JSON.stringify > 80 chars
|
|
215
|
+
const tool = makeTool({
|
|
216
|
+
name: "do_something",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: "object",
|
|
219
|
+
properties: {
|
|
220
|
+
configuration_file_path: { type: "string", description: "Long param" },
|
|
221
|
+
output_destination_directory: { type: "string", description: "Another long one" },
|
|
222
|
+
encryption_algorithm_identifier: { type: "string", description: "Third long one" },
|
|
223
|
+
},
|
|
224
|
+
required: [
|
|
225
|
+
"configuration_file_path",
|
|
226
|
+
"output_destination_directory",
|
|
227
|
+
"encryption_algorithm_identifier",
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
const text = generateHelpText(makeGroup({ tools: [tool] }));
|
|
232
|
+
// Example should just be the bare subcommand name (long args overflow)
|
|
233
|
+
// (param names appear in Parameters section too, so just check the Example line)
|
|
234
|
+
const exampleLine = text.split("\n").find((l) => l.trim().startsWith("Example:"));
|
|
235
|
+
expect(exampleLine).toBe(" Example: do_something");
|
|
236
|
+
});
|
|
197
237
|
});
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ async function main(): Promise<void> {
|
|
|
24
24
|
configPath = args[i + 1] || configPath;
|
|
25
25
|
i++;
|
|
26
26
|
} else if (args[i] === "--version" || args[i] === "-v") {
|
|
27
|
-
console.log("MCPico v0.
|
|
27
|
+
console.log("MCPico v0.2.0");
|
|
28
28
|
process.exit(0);
|
|
29
29
|
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
30
30
|
console.log(`
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth client_credentials provider for MCPico.
|
|
3
|
+
*
|
|
4
|
+
* Implements the MCP SDK's OAuthClientProvider interface so that
|
|
5
|
+
* StreamableHTTPClientTransport handles auth discovery, token exchange,
|
|
6
|
+
* and refresh automatically.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
OAuthClientProvider,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
11
|
+
import type {
|
|
12
|
+
OAuthClientMetadata,
|
|
13
|
+
OAuthClientInformationMixed,
|
|
14
|
+
OAuthTokens,
|
|
15
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
16
|
+
import { loadTokens, saveTokens, clearTokens } from "./token-store.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an OAuthClientProvider for client_credentials grant.
|
|
20
|
+
*
|
|
21
|
+
* Uses a fixed server URL for token storage key and provides
|
|
22
|
+
* client metadata from config. The MCP SDK handles:
|
|
23
|
+
* - RFC 9728 resource metadata discovery
|
|
24
|
+
* - RFC 8414 authorization server metadata discovery
|
|
25
|
+
* - Token exchange
|
|
26
|
+
* - Token refresh via refreshAuthorization()
|
|
27
|
+
*/
|
|
28
|
+
export function createClientCredentialsProvider(
|
|
29
|
+
clientId: string,
|
|
30
|
+
clientSecret: string,
|
|
31
|
+
serverUrl: string,
|
|
32
|
+
): OAuthClientProvider {
|
|
33
|
+
return new ClientCredentialsProvider(clientId, clientSecret, serverUrl);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class ClientCredentialsProvider implements OAuthClientProvider {
|
|
37
|
+
private _tokens: OAuthTokens | undefined;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private clientId: string,
|
|
41
|
+
private clientSecret: string,
|
|
42
|
+
private serverUrl: string,
|
|
43
|
+
) {
|
|
44
|
+
// Load any cached tokens on creation
|
|
45
|
+
this._tokens = loadTokens(serverUrl);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get redirectUrl(): string | URL | undefined {
|
|
49
|
+
// client_credentials is non-interactive — no redirect URL
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
54
|
+
return {
|
|
55
|
+
redirect_uris: ["http://localhost:0/mcplico-callback"],
|
|
56
|
+
grant_types: ["client_credentials"],
|
|
57
|
+
client_name: "MCPico",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Client information for token exchange
|
|
62
|
+
clientInformation(): OAuthClientInformationMixed | undefined {
|
|
63
|
+
return {
|
|
64
|
+
client_id: this.clientId,
|
|
65
|
+
client_secret: this.clientSecret,
|
|
66
|
+
client_secret_expires_at: 0, // Never expires
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Return cached tokens (or undefined for initial auth)
|
|
71
|
+
tokens(): OAuthTokens | undefined {
|
|
72
|
+
return this._tokens;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Persist tokens after successful auth/refresh
|
|
76
|
+
saveTokens(tokens: OAuthTokens): void {
|
|
77
|
+
this._tokens = tokens;
|
|
78
|
+
saveTokens(this.serverUrl, tokens);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// No redirect needed for client_credentials
|
|
82
|
+
redirectToAuthorization(_authorizationUrl: URL): void {
|
|
83
|
+
// Non-interactive — no redirect
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// PKCE not needed for client_credentials
|
|
87
|
+
saveCodeVerifier(_codeVerifier: string): void {
|
|
88
|
+
// No-op for client_credentials
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
codeVerifier(): string {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Use client_credentials grant
|
|
96
|
+
prepareTokenRequest(
|
|
97
|
+
_scope?: string,
|
|
98
|
+
): URLSearchParams | undefined {
|
|
99
|
+
const params = new URLSearchParams({
|
|
100
|
+
grant_type: "client_credentials",
|
|
101
|
+
});
|
|
102
|
+
return params;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Clear tokens on invalidation
|
|
106
|
+
invalidateCredentials(_scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
|
|
107
|
+
this._tokens = undefined;
|
|
108
|
+
clearTokens(this.serverUrl);
|
|
109
|
+
}
|
|
110
|
+
}
|