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. |
| no | Off by default. Pass |
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 };
_meta?: Record<string, unknown>; // passed through to tools/list, e.g. { ui: { resourceUri: 'ui://…' } }
handler(args, ctx: ToolContext): Promise<{ content: { type: 'text'; text: string }[]; structuredContent?: object; isError?: boolean }>;
}
interface ToolContext { ownerId; scopes; grantId; clientId; clientName; request }Resources (MCP Apps)
resources lists static resources next to the tools. Register the HTML of an MCP App under a ui:// URI and point a tool at it through _meta.ui.resourceUri; hosts such as Claude and ChatGPT then render it inline and let it call your other tools with the same grant:
resources: [{
uri: 'ui://my-app/quiz.html', name: 'quiz', mimeType: 'text/html;profile=mcp-app',
read: async (ctx) => [{ uri: 'ui://my-app/quiz.html', mimeType: 'text/html;profile=mcp-app', text: html }],
}]Resources take the same optional scope as tools. resources/list is treated as anonymous discovery; resources/read needs a token. Only fixed URIs are supported (no resource templates).
Keep the HTML user-independent: hosts may prefetch and cache ui:// resources, so per-user data belongs in tool results (structuredContent) that the app fetches with the same grant. ctx is there for authorization and scope checks, not for templating.
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 |
| none (only when | Dev-only browser inspector |
Testing with the inspector
The package ships a small browser page that exercises your server end to end without any MCP client installed: it registers itself as an OAuth client, runs the PKCE flow through your consent page, lists the tools and resources the grant can see, calls tools with JSON arguments and reads resources, showing content, structuredContent and errors verbatim.
Turn it on outside production and open <base>/mcp/inspector on the same origin as your server:
import { inspectorResponse } from '@ninomae/mcp-app-server/inspector';
const mcp = createMcpAppServer({
// ...
inspector: env.NODE_ENV !== 'production' && inspectorResponse,
});
// → GET https://notes.example.com/api/notes/mcp/inspectorThe endpoint field is pre-filled with this server's
<base>/mcp. Click Authorize: the page registers a public client whose redirect URI is its own URL, then sends you to your consent page.Sign in and approve as you normally would. You land back on the inspector with a token in
sessionStorage.Inspect server runs
initialize→tools/list→resources/list. The left pane lists everything the grant can see, split into Read tools, Write tools and Resources (with a filter box); click an entry to open it on the right.A tool's page shows its annotations, input schema, a JSON arguments box and a button that names the operation: Read (tools/call) for tools annotated
readOnlyHint: true, Write (tools/call) for everything else, and Run destructive write (with a confirmation prompt) fordestructiveHint: true. A tool with no annotations is listed under writes and flaggedunannotated, since the page cannot know it is safe. Resources have Read (resources/read). Results stay attached to each entry while you move around the list.
Because the page is served from your origin, no CORS headers are needed, and the redirect URI is https://… (or loopback in local dev), which the registration rules already accept. The HTML is inlined into dist/ at build time and never cached (cache-control: no-store, noindex); when inspector is unset or false the route is not the server's and falls through to your app.
Development and testing only. The inspector is meant for local development, staging and QA. Never enable it in production: it is an unauthenticated public page that lets anyone start an OAuth flow against your server and, once a user has consented, run write tools from a browser. Gate it on your environment (
inspector: env.NODE_ENV !== 'production' && inspectorResponse) or leave it unset, which is the default.
For a standalone copy that runs on http://127.0.0.1:8787 against remote servers, see examples/inspector. Note that this cross-origin mode requires the target server to allow CORS from loopback origins, which this package does not do by default.
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
Account and connection diagnostics
Add connectionInfo: {} to createMcpAppServer to enable the read-only get_connection_info tool. It returns the verified owner ID, OAuth client and grant IDs, granted scopes, configured MCP endpoint, transport, and server name/version in both text and structuredContent. It accepts no account selector. Existing hosts are unchanged unless they opt in. Use resolve to add current application profile and data-environment information:
connectionInfo: {
// toolName: 'myapp_get_connection_info', // optional
resolve: async ({ ownerId }) => {
const user = await users.findById(ownerId);
return {
user: user ? {
displayName: user.displayName,
email: user.email,
identities: [{ provider: 'firebase', subject: user.firebaseUid, issuer: firebaseProjectId }],
} : null,
environment: 'production',
dataSource: 'primary-db',
};
},
},users, firebaseProjectId, and the database label above are host-owned examples. Omit identity entries if no external identity is linked. The resolver receives verified context without the raw request or credentials, runs on every call, and must look up ownerId in the same data store used by business tools. Do not call the consent-page identity.resolve with the agent token or substitute the browser's current user. Return user: null for a deleted user; without a resolver the user status is unknown. A resolver failure is a tool error, not a successful fallback.
Only declared public fields are serialized. Never put secrets, database connection strings, or tokens in profile or environment fields. Anonymous discovery exposes only the tool schema and does not invoke the resolver. Calls require a valid, unrevoked authorization. Custom names must be unique. The tool participates in the normal audit hook.
Compare endpoint + environment/dataSource + owner ID (and external identity issuer/subject) with the website when investigating mismatches. Numeric user IDs from different databases are not comparable on their own. Switching the website account does not switch an existing MCP grant; reconnect with the intended account. This is connection diagnostics, not a data-sync health check. This package's built-in tool describes its HTTP transport; separate legacy stdio servers need their own adapter.
This server cannot be deployed
Maintenance
Related MCP Connectors
Connect any AI agent to 1,000+ apps and 27,000+ actions through one remote MCP server (OAuth).
Give AI agents identity, scoped access, trusted context, and verifiable actions through MCP.
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.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to interact with Drupal sites through MCP tools, with automatic discovery, OAuth-based authentication, and scope validation.12 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.18 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 gradedqualityBmaintenanceEnables AI agents to discover and invoke backend tools over MCP JSON-RPC while enforcing 3-legged OAuth 2.0 identity propagation, role-based access control, and protocol transcoding to REST APIs.Apache 2.0