Skip to main content
Glama
erzhiqianyi

mcp-app-server

by erzhiqianyi

@ninomae/mcp-app-server

Turn your existing app into an OAuth 2.1-protected MCP server.

npm CI license

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/list is trimmed to what the user actually granted.

  • Publicly discoverable. /.well-known/*, GET <basePath>/mcp/schema, and anonymous initialize / tools/list need 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 AppServerStore interface over whatever you run (SQL, KV, Redis, Mongo, an ORM), or use the bundled memoryStore() / sqlStore() (Cloudflare D1, any SQLite driver).

Install

npm install @ninomae/mcp-app-server @modelcontextprotocol/sdk zod

Optional 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/mcp

Claude 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/call

Every 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

name

yes

MCP server name; also used in server.json.

basePath

yes

Where all gateway routes hang: <basePath>/mcp, <basePath>/mcp/schema, <basePath>/oauth/*.

scopes

no

Ordered scope catalogue. required scopes are always granted; default ones are pre-checked on the consent page. Omit it if your app has no scope model: one implicit required scope is used and consent is a plain allow/deny.

identity

yes

IdentityProvider — see Identity.

tools

yes

AgentTool[] — see Tools.

storage

yes

AppServerStore — see Storage.

origins

yes

{ publicOrigin, webOrigin } or a function of the request. publicOrigin is written into metadata and token audience; webOrigin hosts the consent page.

consentPath

no

Default /oauth/authorize.

accessTokenDays / refreshTokenDays

no

Default 30 / 90.

tokenPrefix

no

Default agt_. Lets your own routes recognise agent tokens.

registrationLimit

no

{ limiter, key? } for POST /oauth/register, or false. Default memoryRateLimiter() — see Rate limiting.

onEvent

no

Audit hook: authorized / refreshed / revoked / tool. Never receives tool payloads.

anonymousDiscovery

no

Default true. Let initialize / tools/list answer without a token.

contract

no

Free-text (markdown) data contract published with the schema.

inspector

no

Off by default. Pass inspectorResponse from @ninomae/mcp-app-server/inspector to serve the dev inspector at GET <base>/mcp/inspector — see Testing with the inspector. Development/testing only; never enable in production.

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 only

Or 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 overrides

To 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 IP

The 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 }
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

GET /.well-known/oauth-protected-resource[<mcpPath>]

none

Resource metadata (RFC 9728)

GET /.well-known/oauth-authorization-server

none

Authorization server metadata (RFC 8414)

GET <base>/mcp/schema

none

Server name, endpoint, scopes, JSON Schema of every tool

POST <base>/mcp (initialize / ping / tools/list)

none

Anonymous discovery; full tool list

POST <base>/mcp (anything else)

agent token

MCP Streamable HTTP

POST <base>/oauth/register

none (rate-limited via registrationLimit)

Dynamic client registration

GET <base>/oauth/authorize

none

Validate, then 302 to the consent page

GET <base>/oauth/client

none

Client + scope descriptions for the consent page

POST <base>/oauth/approve

host identity

User approves/denies → authorization code

POST <base>/oauth/token

client

Code exchange, refresh rotation

GET <base>/mcp/inspector

none (only when inspector is set)

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/inspector
  1. The 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.

  2. Sign in and approve as you normally would. You land back on the inspector with a token in sessionStorage.

  3. 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.

  4. 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) for destructiveHint: true. A tool with no annotations is listed under writes and flagged unannotated, 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/call always requires a token; anonymous discovery exposes tool metadata, never data.

  • Cross-site Origin headers 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

MIT

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.

Related MCP Connectors

Related MCP Servers