Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ OPENAI_API_KEY=sk-...
EMBEDDING_PROVIDER=openai
EMBEDDING_MODEL=text-embedding-3-large
EMBEDDING_DIMENSIONS=3072

# Clerk identity for MCP authorization at /mcp.
CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
5 changes: 4 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ jobs:
- run: bun install

- name: Build
run: bun run build
run: bun run build:prod
env:
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }}
VITE_API_URL: https://api.ooxml.dev

- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
Expand Down
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ dist/
.DS_Store
dev/
.wrangler/
.env
.env*
!.env.example
.mcp.json
.vscode/

# Local-only planning doc (public repo)
PLAN.md

# XSD/spec artifacts: pulled by scripts/fetch-xsd.ts; never committed.
data/xsd-cache/
data/xsd-cache/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,22 @@ Three tool families share one server:
- **Schema lookup** (over the parsed XSDs): `ooxml_element`, `ooxml_type`, `ooxml_children`, `ooxml_attributes`, `ooxml_enum`, `ooxml_namespace`
- **Package metadata** (curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x): `ooxml_package_part`

### Authentication

`/mcp` uses OAuth 2.1. Compatible MCP clients register automatically, open the ooxml.dev sign-in and consent pages, and receive a token limited to this MCP server. Clerk handles user identity; the MCP server handles dynamic client registration, PKCE, tokens, refresh, and revocation.

## Development

```bash
bun install # Install dependencies
bun dev # Dev server at http://localhost:5173
bun run build # Production build
bun run build:prod # Build with the ignored .env.prod file
```

`build:prod` requires a live Clerk publishable key. This prevents production deploys from using the
auth fallback or a test Clerk instance.

## Contributing

Contributions welcome. Add implementation notes, fix examples, or improve the reference.
Expand Down
18 changes: 18 additions & 0 deletions apps/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,21 @@ bun run deploy
```

Database setup, ingest pipelines, and tests live at the repo root — see the top-level `README.md`.

## Authentication

`/mcp` uses OAuth 2.1 and serves MCP `2026-07-28`, with stateless compatibility for current 2024/2025 clients. `@cloudflare/workers-oauth-provider` owns discovery, dynamic client registration, Client ID Metadata Documents, PKCE, resource-bound tokens, refresh, and revocation. Clerk authenticates the person on the custom ooxml.dev sign-in page before the server shows consent.

This split is intentional: Clerk identifies users well, but it does not provide the dynamic client registration standard MCP clients need.

Successful tool calls write the Clerk user ID, dynamic OAuth client ID, tool name, surface, and timestamp to `mcp_usage_events`. Tokens and tool arguments are never recorded.

```bash
bun test tests/mcp-server/mcp-auth.test.ts tests/mcp-server/oauth-authorization.test.ts
```

To see identified users, load `DATABASE_URL` and `CLERK_SECRET_KEY` from the root `.env` and run:

```bash
bun run mcp:users
```
7 changes: 5 additions & 2 deletions apps/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clerk/backend": "^3.16.3",
"@cloudflare/workers-oauth-provider": "^0.10.3",
"@modelcontextprotocol/server": "2.0.0",
"@neondatabase/serverless": "^1.0.2",
"@ooxml-dev/shared": "workspace:*",
"@modelcontextprotocol/sdk": "^1.25.3",
"@neondatabase/serverless": "^1.0.2"
"zod": "^4.2.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260127.0",
Expand Down
52 changes: 52 additions & 0 deletions apps/mcp-server/scripts/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createClerkClient } from "@clerk/backend";
import { isClerkAPIResponseError } from "@clerk/backend/errors";
import { neon } from "@neondatabase/serverless";

const databaseUrl = process.env.DATABASE_URL;
const clerkSecretKey = process.env.CLERK_SECRET_KEY;
if (!databaseUrl || !clerkSecretKey) {
throw new Error("DATABASE_URL and CLERK_SECRET_KEY are required");
}

const sql = neon(databaseUrl);
const clerk = createClerkClient({ secretKey: clerkSecretKey, telemetry: { disabled: true } });
const rows = await sql<
Array<{
clerk_user_id: string;
last_seen_at: string;
call_count: string;
tools: string[];
}>
>`
SELECT
clerk_user_id,
MAX(occurred_at)::text AS last_seen_at,
COUNT(*)::text AS call_count,
ARRAY_AGG(DISTINCT tool_name ORDER BY tool_name) AS tools
FROM mcp_usage_events
GROUP BY clerk_user_id
ORDER BY MAX(occurred_at) DESC
LIMIT 100
`;

console.log("USER ID\tNAME\tEMAIL\tCALLS\tLAST SEEN\tTOOLS");
for (const row of rows) {
let name = "";
let email = "";
try {
const user = await clerk.users.getUser(row.clerk_user_id);
name = [user.firstName, user.lastName].filter(Boolean).join(" ");
email =
user.emailAddresses.find((item) => item.id === user.primaryEmailAddressId)?.emailAddress ??
"";
} catch (error) {
if (!isClerkAPIResponseError(error) || error.status !== 404) throw error;
name = "(deleted user)";
}

console.log(
[row.clerk_user_id, name, email, row.call_count, row.last_seen_at, row.tools.join(",")].join(
"\t",
),
);
}
Loading