mcpico 0.1.2 → 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/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
- transport = new StreamableHTTPClientTransport(
67
- new URL(transportConfig.url)
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.1.0" },
94
+ { name: `MCPico-${name}`, version: "0.2.0" },
75
95
  { capabilities: {} }
76
96
  );
77
97
 
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.1.0");
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
+ }
package/src/server.ts CHANGED
@@ -15,6 +15,8 @@ import { groupTools, type ToolGroup } from "./grouper.js";
15
15
  import { parseCommand } from "./parser.js";
16
16
  import { generateHelpText } from "./help.js";
17
17
  import { forwardToolCall } from "./proxy.js";
18
+ import type { ResolvedListenAuth } from "./auth-types.js";
19
+ import { resolveUpstreamAuth, resolveListenAuth, extractBearerToken, validateBearerToken, sendUnauthorized } from "./auth.js";
18
20
 
19
21
  /** Helper: create a simple text content result */
20
22
  export function textResult(text: string): CallToolResult {
@@ -168,10 +170,14 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
168
170
  ? serverConfig.transport.url
169
171
  : serverConfig.transport.command;
170
172
  console.error(` Connecting to "${serverConfig.name}" (${serverConfig.transport.type}: ${transportLabel})...`);
173
+ const resolvedAuth = serverConfig.auth
174
+ ? resolveUpstreamAuth(serverConfig.auth)
175
+ : undefined;
171
176
  const discovered = await discoverServer(
172
177
  serverConfig.name,
173
178
  serverConfig.transport,
174
- serverConfig.connectTimeoutMs
179
+ serverConfig.connectTimeoutMs,
180
+ resolvedAuth
175
181
  );
176
182
  servers.push(discovered);
177
183
 
@@ -205,7 +211,7 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
205
211
 
206
212
  // Create the MCPico server
207
213
  const server = new McpServer(
208
- { name: "MCPico", version: "0.1.0" },
214
+ { name: "MCPico", version: "0.2.0" },
209
215
  {
210
216
  capabilities: { tools: {}, resources: {}, prompts: {} },
211
217
  instructions:
@@ -326,8 +332,23 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
326
332
  const listenConfig = config.listen || { type: "stdio" };
327
333
 
328
334
  if (listenConfig.type === "sse") {
335
+ // Resolve listen auth
336
+ let listenAuth: ResolvedListenAuth | undefined;
337
+ if (listenConfig.auth) {
338
+ listenAuth = resolveListenAuth(listenConfig.auth);
339
+ }
340
+
329
341
  const transport = new StreamableHTTPServerTransport();
330
342
  const httpServer = createServer(async (req, res) => {
343
+ // Auth check
344
+ if (listenAuth) {
345
+ const providedToken = extractBearerToken(req);
346
+ if (!validateBearerToken(providedToken, listenAuth.token)) {
347
+ sendUnauthorized(res);
348
+ return;
349
+ }
350
+ }
351
+
331
352
  // Collect body for handleRequest
332
353
  const chunks: Buffer[] = [];
333
354
  for await (const chunk of req) {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for token store.
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
5
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import {
9
+ storageKey,
10
+ saveTokens,
11
+ loadTokens,
12
+ clearTokens,
13
+ isTokenValid,
14
+ } from "./token-store.js";
15
+
16
+ const TEST_CREDENTIALS_DIR = join(homedir(), ".mcplico");
17
+ const TEST_CREDENTIALS_FILE = join(TEST_CREDENTIALS_DIR, "credentials.json");
18
+
19
+ // Mock homedir to a temp location for test isolation
20
+ const realHomedir = homedir;
21
+
22
+ describe("token-store", () => {
23
+ beforeEach(() => {
24
+ // Clean up between tests
25
+ try {
26
+ rmSync(TEST_CREDENTIALS_FILE, { force: true });
27
+ } catch {}
28
+ });
29
+
30
+ afterEach(() => {
31
+ try {
32
+ rmSync(TEST_CREDENTIALS_FILE, { force: true });
33
+ } catch {}
34
+ });
35
+
36
+ describe("storageKey", () => {
37
+ it("derives key from URL hostname and path", () => {
38
+ expect(storageKey("https://auth.example.com/token")).toBe(
39
+ "auth.example.com/token"
40
+ );
41
+ });
42
+
43
+ it("uses hostname only for root path", () => {
44
+ expect(storageKey("https://auth.example.com")).toBe("auth.example.com");
45
+ });
46
+
47
+ it("handles non-URL strings gracefully", () => {
48
+ const key = storageKey("not-a-url");
49
+ expect(key).toBe("not_a_url");
50
+ });
51
+ });
52
+
53
+ describe("saveTokens and loadTokens", () => {
54
+ it("saves and loads tokens", () => {
55
+ const serverUrl = "https://api.example.com/mcp";
56
+ saveTokens(serverUrl, {
57
+ access_token: "access-123",
58
+ token_type: "Bearer",
59
+ expires_in: 3600,
60
+ refresh_token: "refresh-456",
61
+ });
62
+
63
+ const tokens = loadTokens(serverUrl);
64
+ expect(tokens).toBeDefined();
65
+ expect(tokens!.access_token).toBe("access-123");
66
+ expect(tokens!.token_type).toBe("Bearer");
67
+ expect(tokens!.refresh_token).toBe("refresh-456");
68
+ expect(tokens!.expires_in).toBeGreaterThan(0);
69
+ expect(tokens!.expires_in).toBeLessThanOrEqual(3600);
70
+ });
71
+
72
+ it("returns undefined for unknown server", () => {
73
+ expect(loadTokens("https://unknown.example.com")).toBeUndefined();
74
+ });
75
+
76
+ it("defaults token_type to Bearer if not stored", () => {
77
+ const serverUrl = "https://api.example.com";
78
+ saveTokens(serverUrl, {
79
+ access_token: "at",
80
+ token_type: "Bearer",
81
+ } as any);
82
+
83
+ // Manually strip token_type from the store
84
+ const fs = require("node:fs");
85
+ const raw = fs.readFileSync(TEST_CREDENTIALS_FILE, "utf-8");
86
+ const store = JSON.parse(raw);
87
+ const key = storageKey(serverUrl);
88
+ delete store[key].token_type;
89
+ fs.writeFileSync(TEST_CREDENTIALS_FILE, JSON.stringify(store));
90
+
91
+ const tokens = loadTokens(serverUrl);
92
+ expect(tokens!.token_type).toBe("Bearer");
93
+ });
94
+ });
95
+
96
+ describe("clearTokens", () => {
97
+ it("removes tokens for a server", () => {
98
+ const serverUrl = "https://api.example.com";
99
+ saveTokens(serverUrl, {
100
+ access_token: "at",
101
+ token_type: "Bearer",
102
+ });
103
+ expect(loadTokens(serverUrl)).toBeDefined();
104
+
105
+ clearTokens(serverUrl);
106
+ expect(loadTokens(serverUrl)).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe("isTokenValid", () => {
111
+ it("returns false when no tokens stored", () => {
112
+ expect(isTokenValid("https://nonexistent.example.com")).toBe(false);
113
+ });
114
+
115
+ it("returns true for a freshly saved token", () => {
116
+ const serverUrl = "https://api.example.com";
117
+ saveTokens(serverUrl, {
118
+ access_token: "at",
119
+ token_type: "Bearer",
120
+ expires_in: 3600,
121
+ });
122
+ expect(isTokenValid(serverUrl)).toBe(true);
123
+ });
124
+
125
+ it("returns true for token without expiry info", () => {
126
+ const serverUrl = "https://api.example.com";
127
+ saveTokens(serverUrl, {
128
+ access_token: "at",
129
+ token_type: "Bearer",
130
+ });
131
+ expect(isTokenValid(serverUrl)).toBe(true);
132
+ });
133
+
134
+ it("returns false for expired token", () => {
135
+ const serverUrl = "https://api.example.com";
136
+ // Save token with expires_at in the past
137
+ const fs = require("node:fs");
138
+ saveTokens(serverUrl, {
139
+ access_token: "at",
140
+ token_type: "Bearer",
141
+ expires_in: 3600,
142
+ });
143
+
144
+ // Manually set expires_at to 1 hour ago
145
+ const raw = fs.readFileSync(TEST_CREDENTIALS_FILE, "utf-8");
146
+ const store = JSON.parse(raw);
147
+ const key = storageKey(serverUrl);
148
+ store[key].expires_at = Date.now() - 3600_000;
149
+ fs.writeFileSync(TEST_CREDENTIALS_FILE, JSON.stringify(store));
150
+
151
+ expect(isTokenValid(serverUrl)).toBe(false);
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Token store: persist OAuth tokens to ~/.mcplico/credentials.json
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
8
+
9
+ const CREDENTIALS_DIR = join(homedir(), ".mcplico");
10
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
11
+
12
+ interface StoredToken {
13
+ access_token: string;
14
+ refresh_token?: string;
15
+ token_type?: string;
16
+ expires_at?: number; // Unix timestamp (ms)
17
+ scope?: string;
18
+ }
19
+
20
+ interface CredentialsStore {
21
+ [key: string]: StoredToken;
22
+ }
23
+
24
+ function ensureDir(): void {
25
+ if (!existsSync(CREDENTIALS_DIR)) {
26
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
27
+ }
28
+ }
29
+
30
+ function readStore(): CredentialsStore {
31
+ try {
32
+ if (!existsSync(CREDENTIALS_FILE)) return {};
33
+ const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
34
+ return JSON.parse(raw) as CredentialsStore;
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ function writeStore(store: CredentialsStore): void {
41
+ ensureDir();
42
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(store, null, 2), {
43
+ mode: 0o600,
44
+ });
45
+ }
46
+
47
+ /** Derive a storage key from server URL */
48
+ export function storageKey(serverUrl: string): string {
49
+ try {
50
+ const url = new URL(serverUrl);
51
+ // Use hostname + pathname to distinguish different servers
52
+ return `${url.hostname}${url.pathname === "/" ? "" : url.pathname}`;
53
+ } catch {
54
+ // Fallback for non-URL values
55
+ return serverUrl.replace(/[^a-zA-Z0-9]/g, "_");
56
+ }
57
+ }
58
+
59
+ /** Persist OAuth tokens for a server */
60
+ export function saveTokens(serverUrl: string, tokens: OAuthTokens): void {
61
+ const store = readStore();
62
+ const key = storageKey(serverUrl);
63
+ store[key] = {
64
+ access_token: tokens.access_token,
65
+ refresh_token: tokens.refresh_token,
66
+ token_type: tokens.token_type,
67
+ expires_at: tokens.expires_in
68
+ ? Date.now() + tokens.expires_in * 1000
69
+ : undefined,
70
+ scope: tokens.scope,
71
+ };
72
+ writeStore(store);
73
+ }
74
+
75
+ /** Load persisted OAuth tokens for a server */
76
+ export function loadTokens(serverUrl: string): OAuthTokens | undefined {
77
+ const store = readStore();
78
+ const key = storageKey(serverUrl);
79
+ const stored = store[key];
80
+ if (!stored) return undefined;
81
+ return {
82
+ access_token: stored.access_token,
83
+ refresh_token: stored.refresh_token,
84
+ token_type: stored.token_type || "Bearer",
85
+ expires_in: stored.expires_at
86
+ ? Math.max(0, Math.floor((stored.expires_at - Date.now()) / 1000)) || undefined
87
+ : undefined,
88
+ scope: stored.scope,
89
+ };
90
+ }
91
+
92
+ /** Clear stored tokens (e.g., when invalidated) */
93
+ export function clearTokens(serverUrl: string): void {
94
+ const store = readStore();
95
+ const key = storageKey(serverUrl);
96
+ delete store[key];
97
+ writeStore(store);
98
+ }
99
+
100
+ /** Check if a stored token is still valid (not expired, with 60s buffer) */
101
+ export function isTokenValid(serverUrl: string): boolean {
102
+ const store = readStore();
103
+ const key = storageKey(serverUrl);
104
+ const stored = store[key];
105
+ if (!stored || !stored.access_token) return false;
106
+ if (!stored.expires_at) return true; // No expiry info — assume valid
107
+ // Refresh with 60 second buffer
108
+ return Date.now() < stored.expires_at - 60_000;
109
+ }