mcp-app-server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-app-serverlist my notes and summarize the most recent one"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
@ninomae/mcp-app-server
Turn your existing app into an OAuth 2.1-protected MCP server.
Expose your existing app's users and data to external AI agents (Claude, ChatGPT, Cursor, Claude Code, any MCP client) over standard MCP + OAuth 2.1. You do not integrate a model; users bring their own agent, read their data, work on it there, and write results back through tools you define.
中文文档 · 日本語 · Integration guide · Publishing · Changelog
Bring your own identity. Your app already has a login (Firebase, Supabase, Auth0, Clerk, a session cookie, home-grown). You implement one function:
resolve(request) → { id }.It is the OAuth authorization server. MCP clients require dynamic client registration (RFC 7591), PKCE, resource indicators (RFC 8707), refresh-token rotation and per-agent revocation. Consumer identity providers rarely offer these, so this layer has to live in your app.
A scoped tool table is your product's agent surface. Each tool declares the scope it needs;
tools/listis trimmed to what the user actually granted.Publicly discoverable.
/.well-known/*,GET <basePath>/mcp/schema, and anonymousinitialize/tools/listneed no token, so the server can be submitted to the official MCP Registry or a connector directory.Storage-agnostic. The core depends on no database. Implement the small
AppServerStoreinterface over whatever you run (SQL, KV, Redis, Mongo, an ORM), or use the bundledmemoryStore()/sqlStore()(Cloudflare D1, any SQLite driver).
Install
npm install @ninomae/mcp-app-server @modelcontextprotocol/sdk zodOptional peers: jose (for jwtIdentity / firebaseIdentity) and react (for the /react consent hook). Node ≥ 22 or any Web-standard runtime (Cloudflare Workers, Deno, Bun).
Related MCP server: architecture-map MCP server
Quick start
import { z } from 'zod';
import { createMcpAppServer, sessionIdentity } from '@ninomae/mcp-app-server';
import { sqlStore } from '@ninomae/mcp-app-server/sql';
const mcp = createMcpAppServer({
name: 'notes',
basePath: '/api/notes', // → /api/notes/mcp, /api/notes/mcp/schema, /api/notes/oauth/*
consentPath: '/oauth/authorize', // a page in your front end (see examples/consent-page.tsx)
scopes: {
'notes:read': { description: 'Read your notes', required: true },
'notes:write': { description: 'Attach AI summaries to notes', default: true },
},
identity: sessionIdentity(async (req) => await sessions.userFromCookie(req)), // { id } or null
storage: sqlStore(env.NOTES_DB), // or memoryStore(), or your own AppServerStore
origins: { publicOrigin: 'https://notes.example.com', webOrigin: 'https://notes.example.com' },
tools: [
{
name: 'notes_list',
scope: 'notes:read',
description: 'List the signed-in user’s notes.',
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
handler: async (_args, ctx) => ({ content: [{ type: 'text', text: JSON.stringify(await listNotes(ctx.ownerId)) }] }),
},
{
name: 'notes_summarize',
scope: 'notes:write',
description: 'Store an agent-written summary for one note.',
inputSchema: { id: z.string(), summary: z.string().max(500) },
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
handler: async ({ id, summary }, ctx) => { /* UPDATE … WHERE owner = ctx.ownerId */ },
},
],
});
export default {
async fetch(request: Request, env: Env) {
await mcp.ensureSchema(); // idempotent; delegates to the store
return (await mcp.fetch(request)) ?? app.fetch(request, env);
},
};Then connect an agent:
claude mcp add --transport http notes https://notes.example.com/api/notes/mcpClaude Code opens the browser, your consent page shows the requested scopes, the user approves, and the agent receives a token bound to that user. A full runnable host lives in examples/cloudflare-worker; the same 60 lines back the end-to-end test in tests.
How it works
MCP client ──► GET /.well-known/oauth-protected-resource (RFC 9728)
──► GET /.well-known/oauth-authorization-server (RFC 8414)
──► POST <base>/oauth/register (RFC 7591, no token)
──► GET <base>/oauth/authorize?… ─302─► <webOrigin><consentPath>?…
│ your login + useAgentConsent()
▼
◄─302 code ◄── POST <base>/oauth/approve (host identity, never an agent token)
──► POST <base>/oauth/token (PKCE) → access_token agt_…, refresh_token agr_…
──► POST <base>/mcp Authorization: Bearer agt_… → tools/list (scoped) / tools/callEvery access token is bound to ownerId + clientId + granted scopes + the audience publicOrigin + mcpPath. Tool handlers receive that verified context as ctx; nothing in ctx ever comes from tool input.
Configuration
Option | Required | Meaning |
| yes | MCP server name; also used in |
| yes | Where all gateway routes hang: |
| no | Ordered scope catalogue. |
| yes |
|
| yes |
|
| yes |
|
| yes |
|
| no | Default |
| no | Default 30 / 90. |
| no | Default |
| no |
|
| no | Audit hook: |
| no | Default |
| no | Free-text (markdown) data contract published with the schema. |
Identity
The only contract between your app and the server. id must be stable and never reused; every token is bound to it.
import { sessionIdentity, jwtIdentity, firebaseIdentity, fixedIdentity } from '@ninomae/mcp-app-server';
sessionIdentity(async (req) => await sessions.userFromCookie(req)); // server-side sessions
jwtIdentity({ jwksUrl, issuer, audience }); // Supabase, Auth0, Clerk, Cognito, any OIDC
firebaseIdentity(projectId); // Firebase Authentication preset
fixedIdentity('local'); // single-user / local dev onlyOr implement IdentityProvider yourself: { resolve(request) => Promise<{ id, displayName?, email? }> }, throwing AppServerError(401, …) when nobody is signed in.
Storage
The server must remember registered clients, single-use authorization codes, refresh-token chains and access tokens (all secrets stored as SHA-256). It does so only through AppServerStore, a plain-object repository interface with four collections and two optional hooks:
interface AppServerStore {
clients: { create; get; touch };
codes: { create; get; consume /* atomic, first caller wins */; setIssuedToken };
refreshTokens: { create; get; getByAccessToken; revoke /* atomic */; revokeByAccessToken; setSuccessor };
accessTokens: { create; get; getByHash; listByOwner; touch; revoke };
prune?(now: string): Promise<void>; // delete expired codes / refresh tokens
ensureSchema?(): Promise<void>; // idempotent setup, surfaced as mcp.ensureSchema()
}Two implementations ship with the package:
import { memoryStore } from '@ninomae/mcp-app-server'; // Maps; dev, tests, single process
import { sqlStore } from '@ninomae/mcp-app-server/sql'; // Cloudflare D1 as-is; node:sqlite / better-sqlite3 / libsql with a 10-line wrapper
sqlStore(env.DB, { clients: 'my_clients' }) // optional table-name overridesTo back it with Postgres, Redis, KV, Mongo, Prisma, Drizzle… implement the interface (about 150 lines; src/store.ts is the reference). The two methods marked atomic are what the replay defences rely on: codes.consume and refreshTokens.revoke must return true for exactly one caller. Copy tests/memory-store.test.mjs, swap in your store, and the suite checks both.
Rate limiting
Dynamic client registration is unauthenticated and writes to storage, so it is rate limited. Like storage, the server defines only the contract:
interface RateLimiter { allow(key: string): Promise<boolean> } // false → 429
registrationLimit: { limiter, key?: (request) => string } // key defaults to the client IPThe default memoryRateLimiter({ limit: 20, windowMs: 3_600_000 }) counts in process memory, which is correct on one long-lived server and not on Workers, Lambda or any multi-instance deployment. There, plug in the platform's shared counter — Cloudflare's rate-limiting binding is three lines:
registrationLimit: { limiter: { allow: async (key) => (await env.REGISTER_LIMIT.limit({ key })).success } },registrationLimit: false disables it (e.g. behind your own WAF rule).
Tools
interface AgentTool {
name: string;
scope?: string; // omit for tools every authorised agent may use
description: string;
inputSchema: ZodRawShape; // zod v4 shape; published as JSON Schema
annotations: { readOnlyHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint: boolean };
handler(args, ctx: ToolContext): Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }>;
}
interface ToolContext { ownerId; scopes; grantId; clientId; clientName; request }If your tools are thin wrappers over an existing REST API, forward ctx.request's Authorization header to your own handlers and let that layer accept agent tokens via mcp.authenticate(request).
Gateway API
mcp.fetch(request) // Response | null — mount first in your router
mcp.authenticate(request) // AuthenticatedGrant | null — accept agent tokens on your own routes
mcp.carriesToken(request) // cheap prefix check
mcp.listGrants(ownerId) // which agents are connected, with what scopes, last used when
mcp.revokeGrant(ownerId, grantId) // revoke a grant and its whole refresh chain
mcp.ensureSchema() // idempotent table setup
mcp.describe(request) // the public schema document
mcp.serverJson(request, 'io.github.you', 'One-line description') // MCP Registry server.json
mcp.paths // { mcp, schema, oauth, consent }Consent page (React)
import { useAgentConsent } from '@ninomae/mcp-app-server/react';
const { client, chosen, toggle, decide, error, busy, destination, missingClient } = useAgentConsent({
basePath: '/api/notes',
authHeaders: () => ({ authorization: 'Bearer ' + session.token }), // or omit and rely on cookies
});The hook is headless: render your own login and layout, map client.scopeDetails to checkboxes (required ones disabled), and call decide('approve' | 'deny'). See examples/consent-page.tsx. Non-React front ends can call GET <base>/oauth/client and POST <base>/oauth/approve directly.
Endpoints
Path | Auth | Purpose |
| none | Resource metadata (RFC 9728) |
| none | Authorization server metadata (RFC 8414) |
| none | Server name, endpoint, scopes, JSON Schema of every tool |
| none | Anonymous discovery; full tool list |
| agent token | MCP Streamable HTTP |
| none (rate-limited via | Dynamic client registration |
| none | Validate, then 302 to the consent page |
| none | Client + scope descriptions for the consent page |
| host identity | User approves/denies → authorization code |
| client | Code exchange, refresh rotation |
What the server does not do
It is a bridge, not an authorization system. It never decides whether a user may see a record: ctx.ownerId is the verified user, and your handler enforces your existing rules exactly as it would for a browser session (call your service layer with that id, or re-enter your own REST API with ctx.request's Authorization header and let mcp.authenticate() identify the user there). Scopes are consent, not permissions — they narrow what this agent may do on the user's behalf, and you can skip them entirely.
Security model
User identity comes only from
identity.resolve; an agent token can never approve a grant.Authorization codes live 10 minutes and are single-use; replaying one revokes the tokens it issued. Both rely on the store's atomic
consume/revoke.Refresh tokens rotate; replaying a rotated refresh token cuts the whole chain.
Tokens are stored as SHA-256 only. Audience is
publicOrigin + mcpPath, so moving domains invalidates old tokens.tools/callalways requires a token; anonymous discovery exposes tool metadata, never data.Cross-site
Originheaders on MCP requests are rejected (loopback excepted).Redirect URIs are classified as
loopback/custom/https; anything else is refused at registration.
Compared with Cloudflare workers-oauth-provider
It solves the same OAuth layer with KV storage. This package adds: pluggable storage (any database, D1 included), pluggable host identity, a scoped tool table, grant-trimmed tools/list, a public schema with anonymous discovery, a consent-page hook, grant management APIs and a reusable end-to-end test. If you only need OAuth and none of the above, use the official library.
Development
npm install
npm run typecheck
npm test # Miniflare + D1 end-to-end, memoryStore replay/revocation rules, node:sqlite adapter
npm run build # emits dist/ (ESM + .d.ts)See CONTRIBUTING.md and docs/publishing.md.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
Connect AI agents to Filepad workspaces through OAuth MCP.
MCP server connecting AI agents to 100+ apps (Gmail, Slack, Notion, GitHub) via one-click OAuth.
Zero-setup MCP gateway securely connecting AI to your tools with authentication and workflows
Give AI agents identity, permissions, and reusable proof through one MCP.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to interact with Drupal sites through MCP tools, with automatic discovery, OAuth-based authentication, and scope validation.9 npm5MIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP-compatible AI agents to read and write architecture-map projects and diagrams with per-project access controls via OAuth 2.1/PKCE.5 npm1ISC
- AlicenseNot gradedqualityDmaintenanceEnables MCP-compatible assistants to securely access external systems like Slack through permission-scoped, idempotent tools with tenant isolation, delegated OAuth consent, and an immutable audit trail.MIT
- AlicenseNot gradedqualityCmaintenanceProvides an OAuth-protected, scoped, and audited MCP server that lets AI assistants safely query business data through a fixed set of tools without direct database access.1MIT