e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
265 lines
8.9 KiB
TypeScript
265 lines
8.9 KiB
TypeScript
/**
|
|
* Standalone test MCP server with OAuth.
|
|
*
|
|
* - OAuth 2.1 authorization server (PKCE + DCR) via @modelcontextprotocol/sdk auth router.
|
|
* - One MCP tool, `echo`, that returns its input.
|
|
* - All state in memory; for local development / E2E only.
|
|
*
|
|
* Run: `pnpm dev:server` (defaults to http://localhost:8787)
|
|
*/
|
|
import express from "express";
|
|
import cors from "cors";
|
|
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
import type { OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
|
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
|
|
import type {
|
|
OAuthClientInformationFull,
|
|
OAuthTokens,
|
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
|
import { z } from "zod";
|
|
|
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
const ISSUER = process.env.ISSUER ?? `http://localhost:${PORT}`;
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// In-memory OAuth provider
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
type AuthorizationGrant = {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
codeChallenge: string;
|
|
scopes: string[];
|
|
state?: string;
|
|
};
|
|
|
|
const clients = new Map<string, OAuthClientInformationFull>();
|
|
const authCodes = new Map<string, AuthorizationGrant>();
|
|
const tokens = new Map<string, AuthInfo>();
|
|
const refreshTokens = new Map<string, { clientId: string; scopes: string[] }>();
|
|
|
|
const clientsStore: OAuthRegisteredClientsStore = {
|
|
async getClient(clientId) {
|
|
return clients.get(clientId);
|
|
},
|
|
async registerClient(client) {
|
|
const clientId = `client_${randomUUID()}`;
|
|
const full: OAuthClientInformationFull = {
|
|
...client,
|
|
client_id: clientId,
|
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
};
|
|
clients.set(clientId, full);
|
|
return full;
|
|
},
|
|
};
|
|
|
|
const provider: OAuthServerProvider = {
|
|
get clientsStore() {
|
|
return clientsStore;
|
|
},
|
|
|
|
async authorize(client, params, res) {
|
|
// Auto-approve for the test server.
|
|
const code = randomBytes(24).toString("base64url");
|
|
authCodes.set(code, {
|
|
clientId: client.client_id,
|
|
redirectUri: params.redirectUri,
|
|
codeChallenge: params.codeChallenge,
|
|
scopes: params.scopes ?? [],
|
|
...(params.state !== undefined ? { state: params.state } : {}),
|
|
});
|
|
|
|
const target = new URL(params.redirectUri);
|
|
target.searchParams.set("code", code);
|
|
if (params.state) target.searchParams.set("state", params.state);
|
|
res.redirect(target.toString());
|
|
},
|
|
|
|
async challengeForAuthorizationCode(_client, authorizationCode) {
|
|
const grant = authCodes.get(authorizationCode);
|
|
if (!grant) throw new Error("invalid authorization code");
|
|
return grant.codeChallenge;
|
|
},
|
|
|
|
async exchangeAuthorizationCode(
|
|
client,
|
|
authorizationCode,
|
|
): Promise<OAuthTokens> {
|
|
const grant = authCodes.get(authorizationCode);
|
|
if (!grant) throw new Error("invalid authorization code");
|
|
if (grant.clientId !== client.client_id) {
|
|
throw new Error("authorization code/client mismatch");
|
|
}
|
|
authCodes.delete(authorizationCode);
|
|
|
|
const accessToken = randomBytes(32).toString("base64url");
|
|
const refreshToken = randomBytes(32).toString("base64url");
|
|
tokens.set(accessToken, {
|
|
token: accessToken,
|
|
clientId: client.client_id,
|
|
scopes: grant.scopes,
|
|
expiresAt: Math.floor(Date.now() / 1000) + 60 * 60,
|
|
});
|
|
refreshTokens.set(refreshToken, {
|
|
clientId: client.client_id,
|
|
scopes: grant.scopes,
|
|
});
|
|
return {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
token_type: "Bearer",
|
|
expires_in: 60 * 60,
|
|
scope: grant.scopes.join(" "),
|
|
};
|
|
},
|
|
|
|
async exchangeRefreshToken(
|
|
client,
|
|
refreshToken,
|
|
scopes,
|
|
): Promise<OAuthTokens> {
|
|
const grant = refreshTokens.get(refreshToken);
|
|
if (!grant) throw new Error("invalid refresh token");
|
|
if (grant.clientId !== client.client_id) {
|
|
throw new Error("refresh token/client mismatch");
|
|
}
|
|
const newAccess = randomBytes(32).toString("base64url");
|
|
const newRefresh = randomBytes(32).toString("base64url");
|
|
const effectiveScopes = scopes ?? grant.scopes;
|
|
refreshTokens.delete(refreshToken);
|
|
tokens.set(newAccess, {
|
|
token: newAccess,
|
|
clientId: client.client_id,
|
|
scopes: effectiveScopes,
|
|
expiresAt: Math.floor(Date.now() / 1000) + 60 * 60,
|
|
});
|
|
refreshTokens.set(newRefresh, {
|
|
clientId: client.client_id,
|
|
scopes: effectiveScopes,
|
|
});
|
|
return {
|
|
access_token: newAccess,
|
|
refresh_token: newRefresh,
|
|
token_type: "Bearer",
|
|
expires_in: 60 * 60,
|
|
scope: effectiveScopes.join(" "),
|
|
};
|
|
},
|
|
|
|
async verifyAccessToken(token): Promise<AuthInfo> {
|
|
const info = tokens.get(token);
|
|
if (!info) throw new Error("invalid access token");
|
|
return info;
|
|
},
|
|
};
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// MCP server
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
function buildMcpServer(): McpServer {
|
|
const server = new McpServer({ name: "aui-mcp-test", version: "0.0.1" });
|
|
server.registerTool(
|
|
"echo",
|
|
{
|
|
title: "Echo",
|
|
description: "Echo back the provided text.",
|
|
inputSchema: { text: z.string().describe("Text to echo back") },
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async ({ text }) => ({
|
|
content: [{ type: "text" as const, text: `echo: ${text}` }],
|
|
}),
|
|
);
|
|
server.registerTool(
|
|
"fingerprint",
|
|
{
|
|
title: "Fingerprint",
|
|
description:
|
|
"Return a deterministic fingerprint of a string (sha256 hex).",
|
|
inputSchema: { input: z.string() },
|
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
},
|
|
async ({ input }) => ({
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: createHash("sha256").update(input).digest("hex"),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
return server;
|
|
}
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// Express wiring
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const app = express();
|
|
|
|
app.use(
|
|
cors({
|
|
origin: true,
|
|
credentials: false,
|
|
// Without these the browser won't expose MCP session/auth headers.
|
|
exposedHeaders: ["Mcp-Session-Id", "WWW-Authenticate"],
|
|
allowedHeaders: [
|
|
"Authorization",
|
|
"Content-Type",
|
|
"Mcp-Session-Id",
|
|
"Accept",
|
|
"mcp-protocol-version",
|
|
],
|
|
}),
|
|
);
|
|
|
|
app.use(
|
|
mcpAuthRouter({
|
|
provider,
|
|
issuerUrl: new URL(ISSUER),
|
|
scopesSupported: ["mcp"],
|
|
resourceName: "aui-mcp-test",
|
|
}),
|
|
);
|
|
|
|
const bearer = requireBearerAuth({ verifier: provider });
|
|
|
|
app.post("/mcp", bearer, async (req, res) => {
|
|
try {
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: undefined,
|
|
});
|
|
res.on("close", () => transport.close());
|
|
const server = buildMcpServer();
|
|
await server.connect(transport);
|
|
await transport.handleRequest(req, res);
|
|
} catch (err) {
|
|
console.error("MCP request error:", err);
|
|
if (!res.headersSent) res.status(500).end();
|
|
}
|
|
});
|
|
|
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`MCP test server listening on ${ISSUER}`);
|
|
console.log(
|
|
` - OAuth metadata: ${ISSUER}/.well-known/oauth-authorization-server`,
|
|
);
|
|
console.log(` - MCP endpoint: ${ISSUER}/mcp`);
|
|
});
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|