Skip to content

Commit f62a30f

Browse files
committed
dividing ui-enabled server from non ui enabled
1 parent bf35bc1 commit f62a30f

File tree

9 files changed

+594
-173
lines changed

9 files changed

+594
-173
lines changed

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ COPY src ./src
1515
EXPOSE 3000
1616

1717
# Explicitly disable auth by default in this image (can override at runtime)
18-
ENV DISABLE_AUTH=true
18+
#ENV DISABLE_AUTH=false
1919

2020
# Start the MCP server (uses devDependency tsx)
21-
CMD ["npm", "run", "start"]
21+
CMD ["npm", "run", "start:ui"]

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ Start the OAuth demo (port 3001):
3131
npm run dev:oauth
3232
```
3333

34-
Start the MCP server (port 3000). By default, local runs use opaque tokens via the demo OAuth server (introspection mode). Place your env in `.env` and run dev for hot reload:
34+
## Development modes
3535

36+
**Pure MCP (default):**
3637
```bash
3738
# .env (local opaque token default)
3839
# OAUTH_SERVER_URL defaults to http://localhost:3001
@@ -44,6 +45,14 @@ AUTH_TOKEN_MODE=introspection
4445
npm run dev
4546
```
4647

48+
**MCP + UI Components:**
49+
```bash
50+
# Same .env as above
51+
npm run dev:ui
52+
```
53+
54+
The pure MCP mode focuses on core MCP functionality (auth, tools, endpoints). The UI mode adds rich component experiences with interactive forms and visual feedback.
55+
4756
Tip: If you want to run MCP without auth locally, set the explicit flag (in env or inline):
4857

4958
```bash
@@ -196,6 +205,10 @@ curl -i -X POST http://localhost:3000/mcp \
196205
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet","arguments":{"name":"Elin"}}}'
197206
```
198207

208+
**UI Mode:** The `greet` tool renders a simple component with an input field. In rich-UI clients, it is associated with the template `ui://widget/greet.html` and can initiate tool calls from the iframe when supported. The tool also returns `structuredContent` with `{ name, greeting }` so the component can hydrate initial UI state.
209+
210+
**Pure MCP Mode:** The `greet` tool returns simple text responses without UI components, perfect for learning MCP fundamentals.
211+
199212
Call `count`:
200213

201214
```bash

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
"main": "index.js",
55
"scripts": {
66
"dev": "tsx --watch src/start.ts",
7+
"dev:ui": "tsx --watch src/server-with-ui.ts",
78
"dev:oauth": "tsx --watch src/oauth.ts",
89
"start": "tsx src/start.ts",
10+
"start:ui": "tsx src/server-with-ui.ts",
911
"start:oauth": "tsx src/oauth.ts",
1012
"docker:build": "docker build -t mcp-pluggdax-bare:dev .",
1113
"docker:run": "docker run --rm -p 3000:3000 --name mcp-pluggdax mcp-pluggdax-bare:dev",

src/lib/auth.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { createRemoteJWKSet, jwtVerify, JWTPayload } from "jose";
2+
3+
export interface AuthConfig {
4+
DISABLE_AUTH: boolean;
5+
AUTH_TOKEN_MODE: "introspection" | "jwt";
6+
OAUTH_INTROSPECT_URL: string;
7+
JWT_ISSUER?: string;
8+
JWT_AUDIENCE?: string;
9+
JWT_JWKS_URL?: string;
10+
}
11+
12+
export interface AuthResult {
13+
clientId: string;
14+
scopes: string[];
15+
expiresAt: number;
16+
}
17+
18+
// Bearer auth supporting either introspection (opaque tokens) or JWT validation (JWKS)
19+
export async function verifyAccessToken(token: string, config: AuthConfig, expectedResource?: string): Promise<AuthResult> {
20+
if (!config.DISABLE_AUTH) return { clientId: "dev", scopes: [], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
21+
22+
if (config.AUTH_TOKEN_MODE === "jwt") {
23+
if (!config.JWT_JWKS_URL) throw new Error("JWT_JWKS_URL or JWT_ISSUER required for JWT mode");
24+
const JWKS = createRemoteJWKSet(new URL(config.JWT_JWKS_URL));
25+
const { payload } = await jwtVerify(token, JWKS, {
26+
issuer: config.JWT_ISSUER,
27+
audience: config.JWT_AUDIENCE,
28+
algorithms: ["RS256"]
29+
});
30+
31+
const scopes = parseScopes(payload);
32+
const exp = typeof payload.exp === "number" ? payload.exp : Math.floor(Date.now() / 1000) + 3600;
33+
if (expectedResource && payload.aud && typeof payload.aud === "string" && payload.aud !== expectedResource) {
34+
console.warn(`[auth] audience mismatch: token.aud="${payload.aud}" expected="${expectedResource}"`);
35+
throw new Error("Token not intended for this resource");
36+
}
37+
return {
38+
clientId: (payload.client_id as string) || (payload.sub as string) || "unknown",
39+
scopes,
40+
expiresAt: exp,
41+
};
42+
} else {
43+
// Use external introspection (opaque tokens)
44+
const res = await fetch(config.OAUTH_INTROSPECT_URL, {
45+
method: "POST",
46+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
47+
body: new URLSearchParams({ token }).toString()
48+
});
49+
if (!res.ok) throw new Error(`Introspection failed: ${res.status}`);
50+
const data = await res.json();
51+
if (!data.active) throw new Error("Token inactive");
52+
53+
if (expectedResource && data.aud && data.aud !== expectedResource) {
54+
console.warn(`[auth] audience mismatch: token.aud="${data.aud}" expected="${expectedResource}"`);
55+
throw new Error("Token not intended for this resource");
56+
}
57+
return {
58+
clientId: data.client_id ?? "unknown",
59+
scopes: (data.scope ? String(data.scope).split(" ") : []) as string[],
60+
expiresAt: typeof data.exp === "number" ? data.exp : Math.floor(Date.now() / 1000) + 3600
61+
};
62+
}
63+
}
64+
65+
export function parseScopes(payload: JWTPayload): string[] {
66+
const raw = (payload.scope as string) || (payload.scp as string) || undefined;
67+
if (!raw) return [];
68+
return String(raw).split(" ").filter(Boolean);
69+
}
70+
71+
export function createAuthMiddleware(config: AuthConfig) {
72+
return async (req: any, res: any, next: any) => {
73+
if (config.DISABLE_AUTH) return next();
74+
75+
const baseUrl = `${req.protocol}://${req.get('host')}`;
76+
// Prefer configured audience to avoid http/https host-derived mismatches
77+
const expectedResource = config.JWT_AUDIENCE || `${baseUrl}/mcp`;
78+
const resourceMetadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
79+
80+
try {
81+
const auth = req.headers.authorization;
82+
if (!auth) {
83+
console.warn(`[auth] missing authorization for ${req.method} ${req.originalUrl}`);
84+
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
85+
return res.status(401).json({ error: "missing_authorization" });
86+
}
87+
const [type, token] = auth.split(" ");
88+
if (!token || type.toLowerCase() !== "bearer") {
89+
console.warn(`[auth] invalid authorization header for ${req.method} ${req.originalUrl}: ${auth}`);
90+
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
91+
return res.status(401).json({ error: "invalid_authorization" });
92+
}
93+
const info = await verifyAccessToken(token, config, expectedResource);
94+
if (info.expiresAt < Math.floor(Date.now() / 1000)) {
95+
console.warn(`[auth] token expired for client ${info.clientId}`);
96+
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
97+
return res.status(401).json({ error: "token_expired" });
98+
}
99+
req.auth = info;
100+
next();
101+
} catch (e: any) {
102+
console.warn(`[auth] invalid_token on ${req.method} ${req.originalUrl}: ${e?.message || e}`);
103+
res.set('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="mcp:tools"`);
104+
return res.status(401).json({ error: "invalid_token", message: String(e?.message ?? e) });
105+
}
106+
};
107+
}
108+

src/lib/oauth-metadata.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Express } from "express";
2+
3+
export interface OAuthConfig {
4+
OAUTH_SERVER_URL: string;
5+
SCOPES_SUPPORTED: string[];
6+
}
7+
8+
export function registerOAuthEndpoints(app: Express, config: OAuthConfig) {
9+
// OAuth Protected Resource metadata endpoint (MUST be public - no auth required)
10+
// Note: The oauth-authorization-server endpoint is provided by the OAuth server itself
11+
// (e.g., Auth0 exposes it at https://your-tenant.auth0.com/.well-known/oauth-authorization-server)
12+
app.get("/.well-known/oauth-protected-resource", (req, res) => {
13+
const baseUrl = `${req.protocol}://${req.get('host')}`;
14+
15+
res.json({
16+
resource: `${baseUrl}`, // Your MCP server is the protected resource
17+
authorization_servers: [config.OAUTH_SERVER_URL], // Where to get tokens
18+
scopes_supported: config.SCOPES_SUPPORTED,
19+
bearer_methods_supported: ["header"],
20+
introspection_endpoint: `${config.OAUTH_SERVER_URL}/introspect`
21+
});
22+
});
23+
}
24+

0 commit comments

Comments
 (0)